mr merge --when-ready !520

merged merged by cmc on 2026-09-29 04:03 UTC · krz/gitbay:mr-when-ready-289 into main

22 files changed, +1687 −19

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -40,6 +40,7 @@ browser-only and the iOS build screen unable to say more than the log.
4040| resolve a thread | yes | yes | yes |
4141| comment on a diff line | yes | yes | yes |
4242| merge (all strategies) | yes | yes | yes |
43| merge when ready, cancel | yes | yes | no |
4344| close | yes | yes | yes |
4445| close in favour of another | yes | yes | yes |
4546| create | yes | yes | yes |
.gitbay/wiki/Users.org +28
@@ -453,6 +453,7 @@ gitbay mr checkout 4 # local branch mr/4 from the MR head
453453gitbay mr review 4 --approve # or --request-changes / --comment
454454gitbay mr label 4 --add bug --remove wontfix
455455gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
456gitbay mr merge 4 --when-ready # merge once the gates pass; --cancel dequeues
456457gitbay mr close 4
457458#+end_src
458459
@@ -511,6 +512,33 @@ merge= names every unmet gate at once rather than the first. =mr
511512review= says when a verdict is advisory, which it is from anyone
512513without write access: the gates do not count it.
513514
515=mr merge <n> --when-ready= queues the merge and merges once the gates
516pass, or at once if they already do. It is tried again when a status
517is reported on the head, a review is submitted, a thread is resolved,
518the request is marked ready, and the source branch is pushed; a push
519by someone who can write to the target keeps it queued, and the new
520head has to pass on its own. The merge is made as the user who queued
521it, with their rights checked at that moment. Anything the queuer can
522fix (an unmet gate, a conflict, a branch behind a
523=require_signed_commits= target that needs a rebase) leaves it queued
524with the reason shown on =mr show= and the page.
525
526The queue is bound to the credential it was made with. It is dequeued,
527with the reason on the request's timeline, when:
528
529- the queuer loses write access or their account is disabled;
530- the SSH key or API token it was queued with is removed, revoked,
531 expires, or no longer has full scope (a merge queued from the web
532 rests on the account alone);
533- someone who cannot write to the target pushes to the source branch
534 or retargets the request;
535- the source branch is deleted, or the request is closed.
536
537An expiring key or token cannot queue a merge; use one without an
538expiry, or the web. =mr merge <n> --cancel= dequeues without closing.
539=merge= and =squash= are refused at queue time on a
540=require_signed_commits= repository.
541
514542Those gates apply to =mr merge=. A direct push to a protected branch
515543passes none of them until =require-mr on=: then an existing protected
516544branch refuses every push, including =repo commit-file= and the web
CHANGELOG.org +12
@@ -29,6 +29,18 @@ anything beyond "replace the binary and restart" is needed.
2929 the scratch-repository test: a build failing SSH logins must not
3030 lock the runner out. Run it before pointing the runner back at real
3131 repositories; the Admin page has the steps and the rollback. (#260)
32- =mr merge <n> --when-ready= queues a merge until the gates pass,
33 merging as the queuing user with their rights checked then; tried on
34 a status, a review, a resolved thread, =mr ready= and a push to the
35 source branch. A refusal the queuer can fix, such as a branch behind
36 a =require_signed_commits= target, keeps it queued with the reason
37 on =mr show= and the page. The queue is bound to the key or token it
38 was made with: removing, revoking, expiring or narrowing it
39 dequeues, as do losing write access, a push or retarget by someone
40 who cannot write to the target, deleting the source branch, and
41 closing. An expiring credential cannot queue. =--cancel= dequeues.
42 =mr show=, =mr list= and the dashboard carry =queued=. Migration
43 0067 (#289).
3244
3345* v1.37.0 — 2026-09-29
3446
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/control/build.go +2
@@ -805,6 +805,7 @@ func runRunnerDone(c *Ctx, args []string) int {
805805 }
806806 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
807807 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
808 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
808809 // A red build mails the repo's notify targets with the log tail — a
809810 // failed scheduled job must not wait to be noticed.
810811 if outcome == "failure" {
@@ -1037,6 +1038,7 @@ func resolveCancelledCommitStatus(c *Ctx, repo store.Repo, b store.Build) {
10371038 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
10381039 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
10391040 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
1041 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
10401042 return
10411043 }
10421044 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
internal/control/dashboard.go +6 −4
@@ -40,6 +40,8 @@ type DashboardItem struct {
4040 Author string `json:"author"`
4141 State string `json:"state"`
4242 UpdatedAt string `json:"updated_at"`
43 // Queued marks a merge request with a queued merge.
44 Queued bool `json:"queued,omitempty"`
4345}
4446
4547// PinnedOut is one pinned repository on the dashboard.
@@ -119,7 +121,7 @@ func runDashboard(c *Ctx, args []string) int {
119121 return c.fail(protocol.ExitFailure, "%v", err)
120122 }
121123 for _, m := range mrs {
122 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
124 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
123125 }
124126
125127 reviews, err := c.Store.ReviewQueue(c.User.ID)
@@ -127,7 +129,7 @@ func runDashboard(c *Ctx, args []string) int {
127129 return c.fail(protocol.ExitFailure, "%v", err)
128130 }
129131 for _, m := range reviews {
130 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
132 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
131133 }
132134
133135 assigned, err := c.Store.AssignedIssues(c.User.ID)
@@ -135,7 +137,7 @@ func runDashboard(c *Ctx, args []string) int {
135137 return c.fail(protocol.ExitFailure, "%v", err)
136138 }
137139 for _, i := range assigned {
138 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
140 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
139141 }
140142
141143 issues, err := c.Store.DashboardIssues(c.User.ID)
@@ -143,7 +145,7 @@ func runDashboard(c *Ctx, args []string) int {
143145 return c.fail(protocol.ExitFailure, "%v", err)
144146 }
145147 for _, i := range issues {
146 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
148 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
147149 }
148150
149151 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
internal/control/diffcomment.go +3
@@ -255,6 +255,9 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int {
255255 }
256256 return c.fail(protocol.ExitFailure, "%v", err)
257257 }
258 if resolved {
259 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
260 }
258261 verb := "resolved"
259262 if !resolved {
260263 verb = "reopened"
internal/control/mergequeue.go added +328
@@ -0,0 +1,328 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "strconv"
11 "sync"
12 "time"
13
14 "gitbay.org/gitbay/internal/config"
15 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/protocol"
17 "gitbay.org/gitbay/internal/store"
18)
19
20// QueuedOut is a merge request's queued merge (mr merge --when-ready):
21// who queued it, the strategy it will use ("" for the default), and why
22// the last attempt did not merge.
23type QueuedOut struct {
24 By string `json:"by"`
25 Strategy string `json:"strategy,omitempty"`
26 Reason string `json:"reason,omitempty"`
27 QueuedAt string `json:"queued_at"`
28}
29
30func queuedOut(m store.MR) *QueuedOut {
31 if m.QueuedAt == "" {
32 return nil
33 }
34 return &QueuedOut{By: m.QueuedBy, Strategy: m.QueueStrategy, Reason: m.QueueReason, QueuedAt: m.QueuedAt}
35}
36
37// mergeQueueMu serialises queued merge attempts, so two triggers landing
38// together cannot both merge one request.
39var mergeQueueMu sync.Mutex
40
41type queueResult struct {
42 merged bool
43 dequeued bool
44 reason string
45 out map[string]any // the merge's output, when merged
46}
47
48// TryQueuedMerge attempts the queued merge of one merge request, if it
49// has one. Called wherever a gate can have changed: a review, a resolved
50// thread, a draft marked ready.
51func TryQueuedMerge(st *store.Store, cfg config.Config, mrID int64) {
52 mergeQueueMu.Lock()
53 defer mergeQueueMu.Unlock()
54 attemptLocked(st, cfg, mrID)
55}
56
57// TryQueuedMergesAt attempts the queued merges in a repository whose head
58// is sha. Called when a status is reported on sha.
59func TryQueuedMergesAt(st *store.Store, cfg config.Config, repoID int64, sha string) {
60 ids, err := st.QueuedMRsAtHead(repoID, sha)
61 if err != nil {
62 slog.Error("merge queue: listing", "repo", repoID, "err", err)
63 return
64 }
65 for _, id := range ids {
66 TryQueuedMerge(st, cfg, id)
67 }
68}
69
70// QueuedMergePushed is post-receive's call for a queued merge request
71// whose source branch was pushed by pusherID with a key of scope. A push
72// the target's writers did not make dequeues it, since otherwise the
73// queuer's authority would merge commits someone else chose: a deploy
74// key (which can never merge, whoever registered it) or an account that
75// cannot write to the target. A push that cannot be checked dequeues
76// too. A push from a writer keeps it queued, and the new head has to
77// pass on its own.
78func QueuedMergePushed(st *store.Store, cfg config.Config, mrID, pusherID int64, scope string) {
79 mergeQueueMu.Lock()
80 defer mergeQueueMu.Unlock()
81 mr, err := st.MRByID(mrID)
82 if err != nil {
83 slog.Error("merge queue: push", "mr", mrID, "err", err)
84 st.DequeueMerge(mrID)
85 return
86 }
87 if mr.QueuedAt == "" {
88 return
89 }
90 if policy.IsDeployScope(scope) {
91 dequeueWithReason(st, mr, "a deploy key pushed, and a deploy key cannot merge")
92 return
93 }
94 const unchecked = "could not check who pushed"
95 repo, err := st.RepoByID(mr.RepoID)
96 if err != nil {
97 slog.Error("merge queue: push", "mr", mrID, "err", err)
98 dequeueWithReason(st, mr, unchecked)
99 return
100 }
101 pusher, err := st.UserByID(pusherID)
102 if err != nil {
103 slog.Error("merge queue: push", "mr", mrID, "err", err)
104 dequeueWithReason(st, mr, unchecked)
105 return
106 }
107 grant, err := st.AccessRole(repo.ID, pusher.ID)
108 if err != nil {
109 slog.Error("merge queue: push", "mr", mrID, "err", err)
110 dequeueWithReason(st, mr, unchecked)
111 return
112 }
113 if !policy.CanWrite(pusher, repo, grant) {
114 dequeueWithReason(st, mr, fmt.Sprintf("%s pushed and cannot merge into %s", pusher.Username, repo.Path()))
115 return
116 }
117 attemptLocked(st, cfg, mrID)
118}
119
120// queueSource is Ctx.Source for a merge the queue performs.
121const queueSource = "queue"
122
123// queueInternalReason is what a queued merge says while a lookup fails.
124const queueInternalReason = "internal error, will retry on the next event"
125
126func queueInternalError(st *store.Store, mrID int64, err error) queueResult {
127 slog.Error("merge queue", "mr", mrID, "err", err)
128 st.SetMergeQueueReason(mrID, queueInternalReason)
129 return queueResult{reason: queueInternalReason}
130}
131
132// attemptLocked merges a queued request as the user who queued it,
133// checked against that user's rights and the credential it was queued
134// with now. A merge refused for anything the queuer can fix (unmet
135// gates, a branch behind a require-signed target, a conflict) stays
136// queued with the refusal recorded; a queuer or credential that can no
137// longer merge, or a source branch that is gone, dequeues it. The caller
138// holds mergeQueueMu.
139func attemptLocked(st *store.Store, cfg config.Config, mrID int64) queueResult {
140 mr, err := st.MRByID(mrID)
141 if errors.Is(err, store.ErrNotFound) {
142 return queueResult{}
143 }
144 if err != nil {
145 return queueInternalError(st, mrID, err)
146 }
147 if mr.QueuedAt == "" {
148 return queueResult{}
149 }
150 if mr.State != "open" {
151 return dequeueWithReason(st, mr, "the source branch was deleted")
152 }
153 repo, err := st.RepoByID(mr.RepoID)
154 if err != nil {
155 return queueInternalError(st, mr.ID, err)
156 }
157 user, err := st.UserByID(mr.QueuedByID)
158 if err != nil {
159 return queueInternalError(st, mr.ID, err)
160 }
161 grant, err := st.AccessRole(repo.ID, user.ID)
162 if err != nil {
163 return queueInternalError(st, mr.ID, err)
164 }
165 switch {
166 case user.Disabled || user.Pending:
167 return dequeueWithReason(st, mr, user.Username+"'s account is not active")
168 case !policy.CanWrite(user, repo, grant):
169 return dequeueWithReason(st, mr,
170 fmt.Sprintf("%s no longer has write access to %s", user.Username, repo.Path()))
171 }
172 lapsed, err := queueCredentialLapsed(st, mr.ID, time.Now())
173 if err != nil {
174 return queueInternalError(st, mr.ID, err)
175 }
176 if lapsed != "" {
177 return dequeueWithReason(st, mr, lapsed)
178 }
179
180 var out bytes.Buffer
181 c := &Ctx{User: user, Scope: "full", Source: queueSource, Store: st, Cfg: cfg,
182 Stdin: emptyReader{}, Stdout: &out, Stderr: io.Discard, JSON: true}
183 code := mergeMR(c, repo, mr, mr.QueueStrategy)
184 var env struct {
185 Data map[string]any `json:"data"`
186 Error string `json:"error"`
187 }
188 json.Unmarshal(out.Bytes(), &env)
189 if code == protocol.ExitOK {
190 st.Audit(user.ID, "cmd mr merge", map[string]any{
191 "argv": []string{repo.Path(), strconv.FormatInt(mr.Number, 10), "--when-ready"}, "source": queueSource})
192 return queueResult{merged: true, out: env.Data}
193 }
194 st.SetMergeQueueReason(mr.ID, env.Error)
195 return queueResult{reason: env.Error}
196}
197
198// queueCredentialLapsed says why the key or token a merge was queued with
199// can no longer carry it: removed, expired, or narrowed below full scope.
200// "" means it still can, or the merge was queued from a web session and
201// rests on the account alone.
202func queueCredentialLapsed(st *store.Store, mrID int64, now time.Time) (string, error) {
203 q, err := st.MergeQueueCredential(mrID)
204 if err != nil {
205 return "", err
206 }
207 switch q.Kind {
208 case "key":
209 if q.KeyID == 0 {
210 return "the key it was queued with was removed", nil
211 }
212 k, err := st.SSHKeyByID(q.KeyID)
213 if errors.Is(err, store.ErrNotFound) {
214 return "the key it was queued with was removed", nil
215 }
216 if err != nil {
217 return "", err
218 }
219 if k.Expired(now) {
220 return "the key it was queued with has expired", nil
221 }
222 if k.Scope != "full" {
223 return "the key it was queued with no longer has full scope", nil
224 }
225 case "token":
226 if q.TokenID == 0 {
227 return "the token it was queued with was revoked", nil
228 }
229 t, err := st.APITokenByID(q.TokenID)
230 if errors.Is(err, store.ErrNotFound) {
231 return "the token it was queued with was revoked", nil
232 }
233 if err != nil {
234 return "", err
235 }
236 if t.ExpiresAt != nil && !t.ExpiresAt.After(now) {
237 return "the token it was queued with has expired", nil
238 }
239 if t.Scope != "full" {
240 return "the token it was queued with no longer has full scope", nil
241 }
242 }
243 return "", nil
244}
245
246// dequeueWithReason takes mr off the queue and says why on its timeline,
247// as the queuer, whose request it was.
248func dequeueWithReason(st *store.Store, mr store.MR, reason string) queueResult {
249 st.DequeueMerge(mr.ID)
250 st.AddMRSystemComment(mr.ID, mr.QueuedByID, "dequeued the merge queued by "+mr.QueuedBy+": "+reason)
251 return queueResult{dequeued: true, reason: reason}
252}
253
254// queueMerge is mr merge --when-ready: queue the merge as c.User and try
255// it at once, so gates that already pass merge now.
256func queueMerge(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
257 if code := refuseArchived(c, repo); code >= 0 {
258 return code
259 }
260 if mr.State != "open" {
261 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
262 }
263 // The merge happens later on this credential's authority, which must
264 // not outlive it (#257).
265 if c.Expires != nil {
266 return c.fail(protocol.ExitDenied,
267 "--when-ready merges later on the authority of the credential it is queued with, and this one expires; queue it with a key or token without an expiry, or from the web")
268 }
269 // A strategy the repository refuses outright would wait forever.
270 if repo.Settings.RequireSignedCommits && (strategy == "merge" || strategy == "squash") {
271 return c.fail(protocol.ExitDenied,
272 "%s requires signed commits, so only fast-forward merges are allowed; queue without --strategy or with --strategy ff", repo.Path())
273 }
274 keyID, code := queueKeyID(c)
275 if code >= 0 {
276 return code
277 }
278 if err := c.Store.QueueMerge(mr.ID, c.User.ID, strategy, keyID, c.TokenID); err != nil {
279 return c.fail(protocol.ExitFailure, "%v", err)
280 }
281 mergeQueueMu.Lock()
282 res := attemptLocked(c.Store, c.Cfg, mr.ID)
283 mergeQueueMu.Unlock()
284 switch {
285 case res.merged:
286 return c.emit(res.out, func(w io.Writer) {
287 fmt.Fprintf(w, "merged %s!%d into %s (%v) at %.10v\n", repo.Path(), mr.Number, mr.TargetRef, res.out["strategy"], res.out["sha"])
288 })
289 case res.dequeued:
290 return c.fail(protocol.ExitDenied, "%s", res.reason)
291 }
292 note := ""
293 if strategy != "" {
294 note = " (" + strategy + ")"
295 }
296 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("%s queued the merge%s for when the gates pass", c.User.Username, note))
297 return c.emit(map[string]any{"number": mr.Number, "queued": true, "strategy": strategy, "reason": res.reason}, func(w io.Writer) {
298 fmt.Fprintf(w, "queued %s!%d to merge when ready; waiting: %s\n", repo.Path(), mr.Number, res.reason)
299 })
300}
301
302// queueKeyID is the SSH key behind c, 0 for a token, a web session, or
303// a context with no credential. -1 as the code means go on.
304func queueKeyID(c *Ctx) (int64, int) {
305 if c.TokenID != 0 || c.Source == "" || c.Source == SourceWeb || c.Source == "api" {
306 return 0, -1
307 }
308 k, err := c.Store.SSHKeyByFingerprint(c.Source)
309 if err != nil {
310 return 0, c.fail(protocol.ExitFailure, "looking up the key behind this session: %v", err)
311 }
312 return k.ID, -1
313}
314
315// cancelQueuedMerge is mr merge --cancel.
316func cancelQueuedMerge(c *Ctx, repo store.Repo, mr store.MR) int {
317 ok, err := c.Store.DequeueMerge(mr.ID)
318 if err != nil {
319 return c.fail(protocol.ExitFailure, "%v", err)
320 }
321 if !ok {
322 return c.fail(protocol.ExitFailure, "!%d is not queued to merge", mr.Number)
323 }
324 c.Store.AddMRSystemComment(mr.ID, c.User.ID, c.User.Username+" cancelled the queued merge")
325 return c.emit(map[string]any{"number": mr.Number, "queued": false}, func(w io.Writer) {
326 fmt.Fprintf(w, "cancelled the queued merge of %s!%d\n", repo.Path(), mr.Number)
327 })
328}
internal/control/mergequeue_test.go added +538
@@ -0,0 +1,538 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "gitbay.org/gitbay/internal/config"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// queueFixture is a repository on disk with one merge request, !1,
19// feature into main, whose head is one commit ahead of main.
20type queueFixture struct {
21 t *testing.T
22 st *store.Store
23 repo store.Repo
24 alice store.User // the owner
25 root string
26 dir string
27 src string
28 git func(dir string, args ...string) string
29 headSHA string
30 targetSH string
31}
32
33func newQueueFixture(t *testing.T, set func(*store.RepoSettings)) *queueFixture {
34 t.Helper()
35 st, repo, uid := newQueueTestRepo(t)
36 if set != nil {
37 if _, err := st.UpdateRepoSettings(repo.ID, set); err != nil {
38 t.Fatal(err)
39 }
40 var err error
41 if repo, err = st.RepoByID(repo.ID); err != nil {
42 t.Fatal(err)
43 }
44 }
45 f := &queueFixture{t: t, st: st, repo: repo, alice: store.User{ID: uid, Username: "alice"},
46 root: t.TempDir(), git: gitRunner(t)}
47 f.src = filepath.Join(f.root, "src")
48 f.git(f.root, "init", "-q", "-b", "main", "src")
49 f.write("README", "x\n")
50 f.git(f.src, "add", ".")
51 f.git(f.src, "commit", "-q", "-m", "base")
52 f.targetSH = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
53 f.git(f.src, "checkout", "-q", "-b", "feature")
54 f.write("feature.txt", "y\n")
55 f.git(f.src, "add", ".")
56 f.git(f.src, "commit", "-q", "-m", "change")
57 f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
58
59 f.dir = RepoDir(f.root, repo.OwnerName, repo.Name)
60 os.MkdirAll(filepath.Dir(f.dir), 0o755)
61 f.git(f.root, "clone", "-q", "--bare", f.src, f.dir)
62 f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA)
63 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", f.headSHA, "md", false); err != nil {
64 t.Fatal(err)
65 }
66 return f
67}
68
69func (f *queueFixture) write(name, body string) {
70 f.t.Helper()
71 if err := os.WriteFile(filepath.Join(f.src, name), []byte(body), 0o644); err != nil {
72 f.t.Fatal(err)
73 }
74}
75
76// run dispatches argv as u, returning the exit code and stderr.
77func (f *queueFixture) run(u store.User, argv ...string) (int, string, string) {
78 f.t.Helper()
79 return f.runWith(u, nil, argv...)
80}
81
82// runWith is run with the Ctx adjusted first, for the credential behind
83// the request.
84func (f *queueFixture) runWith(u store.User, adjust func(*Ctx), argv ...string) (int, string, string) {
85 f.t.Helper()
86 var out, errOut bytes.Buffer
87 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut}
88 c.Cfg.Server.Root = f.root
89 if adjust != nil {
90 adjust(c)
91 }
92 code := Dispatch(c, argv)
93 return code, out.String(), errOut.String()
94}
95
96func (f *queueFixture) mustRun(u store.User, argv ...string) string {
97 f.t.Helper()
98 code, out, errOut := f.run(u, argv...)
99 if code != protocol.ExitOK {
100 f.t.Fatalf("%v: exit %d, %s", argv, code, errOut)
101 }
102 return out
103}
104
105func (f *queueFixture) mr() store.MR {
106 f.t.Helper()
107 mr, err := f.st.MRByNumber(f.repo.ID, 1)
108 if err != nil {
109 f.t.Fatal(err)
110 }
111 return mr
112}
113
114// user creates an account granted role on the repository.
115func (f *queueFixture) user(name, role string) store.User {
116 f.t.Helper()
117 id, err := f.st.CreateUser(name, false)
118 if err != nil {
119 f.t.Fatal(err)
120 }
121 if role != "" {
122 if err := f.st.GrantAccess(f.repo.ID, id, role); err != nil {
123 f.t.Fatal(err)
124 }
125 }
126 u, err := f.st.UserByID(id)
127 if err != nil {
128 f.t.Fatal(err)
129 }
130 return u
131}
132
133// systemComments is every system comment on !1, joined.
134func (f *queueFixture) systemComments() string {
135 f.t.Helper()
136 cs, err := f.st.ListMRComments(f.mr().ID)
137 if err != nil {
138 f.t.Fatal(err)
139 }
140 var b strings.Builder
141 for _, c := range cs {
142 if c.Kind == "system" {
143 b.WriteString(c.Body + "\n")
144 }
145 }
146 return b.String()
147}
148
149func (f *queueFixture) wantMergedBy(who string) {
150 f.t.Helper()
151 mr := f.mr()
152 if mr.State != "merged" || mr.MergedBy != who || mr.QueuedAt != "" {
153 f.t.Fatalf("MR = state %s merged_by %q queued_at %q, want merged by %s and off the queue",
154 mr.State, mr.MergedBy, mr.QueuedAt, who)
155 }
156 if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != mr.HeadSHA {
157 f.t.Fatalf("main = %s, want the MR head %s", main, mr.HeadSHA)
158 }
159}
160
161func (f *queueFixture) wantQueued(reason string) store.MR {
162 f.t.Helper()
163 mr := f.mr()
164 if mr.State != "open" || mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, reason) {
165 f.t.Fatalf("MR = state %s queued_at %q reason %q, want open and queued with %q",
166 mr.State, mr.QueuedAt, mr.QueueReason, reason)
167 }
168 return mr
169}
170
171// Gates that already pass merge at once: queueing is only for waiting.
172func TestWhenReadyMergesAtOnceWhenGatesPass(t *testing.T) {
173 f := newQueueFixture(t, nil)
174 out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
175 if !strings.Contains(out, "merged") {
176 t.Fatalf("output = %q, want it to say merged", out)
177 }
178 f.wantMergedBy("alice")
179}
180
181// A status that turns the checks green merges the queued request, as the
182// user who queued it, and mr show carries the queue until then.
183func TestWhenReadyStatusSetMerges(t *testing.T) {
184 f := newQueueFixture(t, func(s *store.RepoSettings) {
185 s.RequireChecks = true
186 s.RequiredContexts = []string{"ext/test"}
187 })
188 bob := f.user("bob", "write")
189 out := f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "ff")
190 if !strings.Contains(out, "queued") {
191 t.Fatalf("output = %q, want it to say queued", out)
192 }
193 f.wantQueued("green checks")
194 got := mrShowJSONAt(t, f, f.alice)
195 if got.Queued == nil || got.Queued.By != "bob" || got.Queued.Strategy != "ff" || !strings.Contains(got.Queued.Reason, "green checks") {
196 t.Fatalf("mr show queued = %+v", got.Queued)
197 }
198
199 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "pending")
200 f.wantQueued("green checks")
201 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
202 f.wantMergedBy("bob")
203}
204
205func mrShowJSONAt(t *testing.T, f *queueFixture, u store.User) mrOut {
206 t.Helper()
207 var out, errOut bytes.Buffer
208 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut}
209 c.Cfg.Server.Root = f.root
210 if code := Dispatch(c, []string{"mr", "show", f.repo.Path(), "1", "--json"}); code != protocol.ExitOK {
211 t.Fatalf("mr show: exit %d, %s", code, errOut.String())
212 }
213 var env struct {
214 Data mrOut `json:"data"`
215 }
216 if err := json.Unmarshal(out.Bytes(), &env); err != nil {
217 t.Fatalf("mr show JSON: %v\n%s", err, out.String())
218 }
219 return env.Data
220}
221
222// An approval that meets require_approvals merges the queued request.
223func TestWhenReadyReviewMerges(t *testing.T) {
224 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
225 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
226 f.wantQueued("approval")
227 bob := f.user("bob", "write")
228 f.mustRun(bob, "mr", "review", f.repo.Path(), "1", "--approve")
229 f.wantMergedBy("alice")
230}
231
232// Resolving the last open thread merges the queued request.
233func TestWhenReadyThreadResolveMerges(t *testing.T) {
234 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true })
235 id, err := f.st.AddDiffComment(f.mr().ID, f.alice.ID, f.headSHA, "feature.txt", "new", 1, "why?", 0, false)
236 if err != nil {
237 t.Fatal(err)
238 }
239 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
240 f.wantQueued("threads resolved")
241 f.mustRun(f.alice, "mr", "resolve", f.repo.Path(), "1", strconv.FormatInt(id, 10))
242 f.wantMergedBy("alice")
243}
244
245// Marking a draft ready merges the queued request.
246func TestWhenReadyReadyMerges(t *testing.T) {
247 f := newQueueFixture(t, nil)
248 f.st.SetMRDraft(f.mr().ID, true)
249 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
250 f.wantQueued("draft")
251 f.mustRun(f.alice, "mr", "ready", f.repo.Path(), "1")
252 f.wantMergedBy("alice")
253}
254
255// A new head keeps the request queued and has to pass on its own: a
256// status on the old head moves nothing.
257func TestWhenReadyNewHeadMustPassAgain(t *testing.T) {
258 f := newQueueFixture(t, func(s *store.RepoSettings) {
259 s.RequireChecks = true
260 s.RequiredContexts = []string{"ext/test"}
261 })
262 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
263 old := f.headSHA
264 f.write("feature.txt", "z\n")
265 f.git(f.src, "commit", "-q", "-am", "more")
266 f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
267 f.git(f.src, "push", "-q", f.dir, "feature")
268 f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA)
269 if err := f.st.UpdateMRHead(f.mr().ID, f.headSHA, f.targetSH, false); err != nil {
270 t.Fatal(err)
271 }
272 TryQueuedMerge(f.st, f.cfg(), f.mr().ID)
273 f.wantQueued("green checks")
274 f.mustRun(f.alice, "status", "set", f.repo.Path(), old, "--context", "ext/test", "--state", "success")
275 f.wantQueued("green checks")
276 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
277 f.wantMergedBy("alice")
278}
279
280func (f *queueFixture) cfg() (c config.Config) {
281 c.Server.Root = f.root
282 return c
283}
284
285// On a require-signed repository the server cannot rebase: a branch
286// behind its target stays queued and says it needs a rebase.
287func TestWhenReadySignedBehindStaysQueued(t *testing.T) {
288 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true })
289 f.git(f.src, "checkout", "-q", "main")
290 f.write("other.txt", "o\n")
291 f.git(f.src, "add", ".")
292 f.git(f.src, "commit", "-q", "-m", "target moves")
293 f.git(f.src, "push", "-q", f.dir, "main")
294 out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
295 if !strings.Contains(out, "rebase and push, and the merge stays queued") {
296 t.Fatalf("output = %q, want the pending reason to name the rebase", out)
297 }
298 f.wantQueued("is behind main")
299
300 // A strategy that cannot ever pass there is refused, not queued.
301 code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "squash")
302 if code != protocol.ExitDenied || !strings.Contains(errOut, "signed") {
303 t.Fatalf("squash on require-signed: exit %d, %s", code, errOut)
304 }
305}
306
307// A queuer who lost write access is dequeued with the reason recorded,
308// and nothing merges.
309func TestWhenReadyRightsLossDequeues(t *testing.T) {
310 f := newQueueFixture(t, func(s *store.RepoSettings) {
311 s.RequireChecks = true
312 s.RequiredContexts = []string{"ext/test"}
313 })
314 bob := f.user("bob", "write")
315 f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready")
316 f.wantQueued("green checks")
317 if err := f.st.RevokeAccess(f.repo.ID, bob.ID); err != nil {
318 t.Fatal(err)
319 }
320 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
321 mr := f.mr()
322 if mr.State != "open" || mr.QueuedAt != "" {
323 t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt)
324 }
325 f.wantDequeued("bob no longer has write access")
326}
327
328// --cancel dequeues; a second cancel has nothing to take off.
329func TestWhenReadyCancel(t *testing.T) {
330 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
331 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
332 f.wantQueued("approval")
333 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel")
334 if mr := f.mr(); mr.QueuedAt != "" {
335 t.Fatalf("cancel left the MR queued: %+v", mr)
336 }
337 code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel")
338 if code != protocol.ExitFailure || !strings.Contains(errOut, "not queued") {
339 t.Fatalf("second cancel: exit %d, %s", code, errOut)
340 }
341 code, _, _ = f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel", "--when-ready")
342 if code != protocol.ExitUsage {
343 t.Fatalf("--cancel --when-ready: exit %d, want usage", code)
344 }
345 // An approval after the cancel merges nothing.
346 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
347 if mr := f.mr(); mr.State != "open" {
348 t.Fatalf("cancelled MR merged: %+v", mr)
349 }
350}
351
352// Closing a queued merge request dequeues it.
353func TestWhenReadyCloseDequeues(t *testing.T) {
354 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
355 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
356 f.mustRun(f.alice, "mr", "close", f.repo.Path(), "1")
357 if mr := f.mr(); mr.State != "closed" || mr.QueuedAt != "" {
358 t.Fatalf("closed MR = %+v, want closed and dequeued", mr)
359 }
360}
361
362// wantDequeued checks !1 is open, off the queue, and its timeline says why.
363func (f *queueFixture) wantDequeued(why string) {
364 f.t.Helper()
365 mr := f.mr()
366 if mr.State != "open" || mr.QueuedAt != "" {
367 f.t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt)
368 }
369 if sys := f.systemComments(); !strings.Contains(sys, "dequeued the merge queued by") || !strings.Contains(sys, why) {
370 f.t.Fatalf("system comments = %q, want a dequeue saying %q", sys, why)
371 }
372}
373
374// branch points a new branch of the bare repository at sha.
375func (f *queueFixture) branch(name, sha string) {
376 f.t.Helper()
377 f.git(f.dir, "update-ref", "refs/heads/"+name, sha)
378}
379
380// Retargeting a queued merge request by someone who cannot write to the
381// repository dequeues it; by someone who can, it stays queued.
382func TestWhenReadyRetargetByNonWriterDequeues(t *testing.T) {
383 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
384 f.branch("dev", f.targetSH)
385 f.branch("dev2", f.targetSH)
386 // carol authored it and can read, so she may retarget it.
387 carol := f.user("carol", "read")
388 if _, err := f.st.DB.Exec("UPDATE merge_requests SET author_id = ? WHERE id = ?", carol.ID, f.mr().ID); err != nil {
389 t.Fatal(err)
390 }
391 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
392 f.mustRun(f.alice, "mr", "retarget", f.repo.Path(), "1", "dev")
393 f.wantQueued("approval")
394 f.mustRun(carol, "mr", "retarget", f.repo.Path(), "1", "dev2")
395 f.wantDequeued("carol retargeted it to dev2 and cannot merge")
396}
397
398// The stack moving up after a merge retargets the merge requests on it,
399// and a queued one stays queued.
400func TestWhenReadyStackRetargetKeepsQueue(t *testing.T) {
401 f := newQueueFixture(t, func(s *store.RepoSettings) {
402 s.RequireChecks = true
403 s.RequiredContexts = []string{"ext/test"}
404 })
405 f.write("stacked.txt", "s\n")
406 f.git(f.src, "add", ".")
407 f.git(f.src, "commit", "-q", "-m", "stacked")
408 stacked := strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
409 f.git(f.src, "push", "-q", f.dir, "HEAD:refs/heads/feature2")
410 f.git(f.dir, "update-ref", mrHeadRef(2), stacked)
411 if _, err := f.st.CreateMR(f.repo.ID, f.alice.ID, f.repo.ID, "feature2", "feature", "two", "", stacked, "md", false); err != nil {
412 t.Fatal(err)
413 }
414 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "2", "--when-ready")
415 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
416 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1")
417 two, err := f.st.MRByNumber(f.repo.ID, 2)
418 if err != nil {
419 t.Fatal(err)
420 }
421 if two.TargetRef != "main" || two.State != "open" || two.QueuedAt == "" {
422 t.Fatalf("!2 after !1 merged = target %s state %s queued_at %q, want main, open and queued", two.TargetRef, two.State, two.QueuedAt)
423 }
424}
425
426// A merge queued with an SSH key is dequeued when the key is removed.
427func TestWhenReadyRemovedKeyDequeues(t *testing.T) {
428 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
429 if err := f.st.AddSSHKey(f.alice.ID, "SHA256:alice", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
430 t.Fatal(err)
431 }
432 withKey := func(c *Ctx) { c.Source = "SHA256:alice" }
433 if code, _, errOut := f.runWith(f.alice, withKey, "mr", "merge", f.repo.Path(), "1", "--when-ready"); code != protocol.ExitOK {
434 t.Fatalf("queue: exit %d, %s", code, errOut)
435 }
436 if err := f.st.RemoveSSHKey(f.alice.ID, "SHA256:alice"); err != nil {
437 t.Fatal(err)
438 }
439 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
440 f.wantDequeued("the key it was queued with was removed")
441}
442
443// A merge queued with an API token is dequeued when the token is revoked.
444func TestWhenReadyRevokedTokenDequeues(t *testing.T) {
445 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
446 if err := f.st.CreateAPIToken(f.alice.ID, "ci", "hash", "full", nil, 0); err != nil {
447 t.Fatal(err)
448 }
449 toks, err := f.st.ListAPITokens(f.alice.ID)
450 if err != nil || len(toks) != 1 {
451 t.Fatalf("tokens = %v, %v", toks, err)
452 }
453 withToken := func(c *Ctx) { c.Source, c.TokenID, c.ViaAPI = "api", toks[0].ID, true }
454 if code, _, errOut := f.runWith(f.alice, withToken, "mr", "merge", f.repo.Path(), "1", "--when-ready"); code != protocol.ExitOK {
455 t.Fatalf("queue: exit %d, %s", code, errOut)
456 }
457 if _, err := f.st.RevokeAPIToken(f.alice.ID, "ci", false); err != nil {
458 t.Fatal(err)
459 }
460 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
461 f.wantDequeued("the token it was queued with was revoked")
462}
463
464// An expiring credential cannot queue a merge: the merge would happen on
465// its authority after it lapsed.
466func TestWhenReadyExpiringCredentialRefused(t *testing.T) {
467 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
468 soon := time.Now().Add(time.Hour)
469 code, _, errOut := f.runWith(f.alice, func(c *Ctx) { c.Source, c.TokenID, c.Expires = "api", 7, &soon },
470 "mr", "merge", f.repo.Path(), "1", "--when-ready")
471 if code != protocol.ExitDenied || !strings.Contains(errOut, "without an expiry, or from the web") {
472 t.Fatalf("exit %d, %s", code, errOut)
473 }
474 if mr := f.mr(); mr.QueuedAt != "" {
475 t.Fatalf("expiring credential queued the merge: %+v", mr)
476 }
477}
478
479// A merge queued from the web rests on the account alone and merges.
480func TestWhenReadyWebQueuedMerges(t *testing.T) {
481 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
482 web := func(c *Ctx) { c.Source, c.ViaAPI = SourceWeb, true }
483 if code, _, errOut := f.runWith(f.alice, web, "mr", "merge", f.repo.Path(), "1", "--when-ready"); code != protocol.ExitOK {
484 t.Fatalf("queue: exit %d, %s", code, errOut)
485 }
486 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
487 f.wantMergedBy("alice")
488}
489
490// runner done reporting the last required check green merges the queued
491// merge request.
492func TestWhenReadyRunnerDoneMerges(t *testing.T) {
493 f := newQueueFixture(t, func(s *store.RepoSettings) {
494 s.RequireChecks = true
495 s.RequiredContexts = []string{"ci/unit"}
496 })
497 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
498 f.wantQueued("ci/unit=missing")
499 if _, err := f.st.CreateBuild(f.repo.ID, "unit", f.headSHA, "feature", `["true"]`, "", "", true); err != nil {
500 t.Fatal(err)
501 }
502 b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}, false)
503 if err != nil || !ok {
504 t.Fatalf("claim: ok=%v err=%v", ok, err)
505 }
506 c, out := runnerCtx(f.st, f.alice.ID, f.root)
507 if code := runRunnerDone(c, []string{strconv.FormatInt(b.ID, 10), "success"}); code != protocol.ExitOK {
508 t.Fatalf("runner done: exit %d\n%s", code, out.String())
509 }
510 f.wantMergedBy("alice")
511}
512
513// Cancelling a build whose job already passed on the same commit puts
514// the success back, and that merges the queued merge request.
515func TestWhenReadyCancelledBuildSuccessMerges(t *testing.T) {
516 f := newQueueFixture(t, func(s *store.RepoSettings) {
517 s.RequireChecks = true
518 s.RequiredContexts = []string{"ci/unit"}
519 })
520 if _, err := f.st.CreateBuild(f.repo.ID, "unit", f.headSHA, "feature", `["true"]`, "", "", true); err != nil {
521 t.Fatal(err)
522 }
523 b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}, false)
524 if err != nil || !ok {
525 t.Fatalf("claim: ok=%v err=%v", ok, err)
526 }
527 if err := f.st.FinishBuild(b.ID, "success"); err != nil {
528 t.Fatal(err)
529 }
530 n, err := f.st.CreateBuild(f.repo.ID, "unit", f.headSHA, "other", `["true"]`, "", "", true)
531 if err != nil {
532 t.Fatal(err)
533 }
534 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
535 f.wantQueued("ci/unit=missing")
536 f.mustRun(f.alice, "build", "cancel", f.repo.Path(), strconv.FormatInt(n, 10))
537 f.wantMergedBy("alice")
538}
internal/control/mr.go +55 −4
@@ -188,11 +188,13 @@ func init() {
188188 Run: runMRLabel})
189189 register(Command{Path: []string{"mr", "merge"},
190190 Summary: "merge",
191 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]",
191 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase] [--when-ready | --cancel]",
192192 Flags: []Flag{
193193 {"--strategy", "ff|merge|squash|rebase", "how to merge", ""},
194 {"--when-ready", "", "queue the merge until the gates pass; merges now if they already do", ""},
195 {"--cancel", "", "take a queued merge off the queue", ""},
194196 },
195 Examples: []string{"mr merge krz/gitbay 431 --strategy ff"},
197 Examples: []string{"mr merge krz/gitbay 431 --strategy ff", "mr merge krz/gitbay 431 --when-ready", "mr merge krz/gitbay 431 --cancel"},
196198 Run: runMRMerge})
197199 register(Command{Path: []string{"mr", "close"},
198200 Summary: "close without merging",
@@ -560,6 +562,8 @@ type mrOut struct {
560562 // SupersededBy is the merge request, by number, this one was closed
561563 // in favour of. 0 means none.
562564 SupersededBy int64 `json:"superseded_by,omitempty"`
565 // Queued is the merge queued with mr merge --when-ready, if any.
566 Queued *QueuedOut `json:"queued,omitempty"`
563567}
564568
565569type stackRef struct {
@@ -605,7 +609,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
605609 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
606610 Labels: m.Labels, ReviewRequests: m.ReviewRequests,
607611 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
608 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy}
612 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy,
613 Queued: queuedOut(m)}
609614 if withBody {
610615 o.Body = m.Body
611616 o.BodyFormat = m.BodyFormat
@@ -795,6 +800,15 @@ func runMRShow(c *Ctx, args []string) int {
795800 gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff)
796801 }
797802 }
803 queued, waiting := "", ""
804 if q := d.Queued; q != nil {
805 queued = "by " + q.By
806 if q.Strategy != "" {
807 queued += " (" + q.Strategy + ")"
808 }
809 queued += ", " + c.when(q.QueuedAt)
810 waiting = q.Reason
811 }
798812 unresolved := ""
799813 if d.UnresolvedThreads > 0 {
800814 unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
@@ -815,6 +829,8 @@ func runMRShow(c *Ctx, args []string) int {
815829 "merged", merged,
816830 "closed", closed,
817831 "superseded by", superseded,
832 "queued to merge", queued,
833 "waiting on", waiting,
818834 "unresolved threads", unresolved,
819835 "gates", gates,
820836 }
@@ -1031,6 +1047,16 @@ func runMRRetarget(c *Ctx, args []string) int {
10311047 return c.fail(protocol.ExitFailure, "%v", err)
10321048 }
10331049 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
1050 // A queued merge was asked for against the old target. Someone who
1051 // could not merge it themselves does not get to point it elsewhere.
1052 if mr.QueuedAt != "" {
1053 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1054 if err != nil || !policy.CanWrite(c.User, repo, grant) {
1055 mergeQueueMu.Lock()
1056 dequeueWithReason(c.Store, mr, fmt.Sprintf("%s retargeted it to %s and cannot merge into %s", c.User.Username, target, repo.Path()))
1057 mergeQueueMu.Unlock()
1058 }
1059 }
10341060 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
10351061 fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
10361062 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
@@ -1108,6 +1134,7 @@ func runMRReview(c *Ctx, args []string) int {
11081134 }
11091135 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed",
11101136 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
1137 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
11111138 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
11121139 notify(c, parts, notice{repo: repo, kind: "mr",
11131140 subject: mrSubject(repo, mr.Number, mr.Title),
@@ -1258,7 +1285,8 @@ func runMRLabel(c *Ctx, args []string) int {
12581285}
12591286
12601287func runMRMerge(c *Ctx, args []string) int {
1261 f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
1288 f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, Bools: []string{"--when-ready", "--cancel"},
1289 MaxPos: -1, Usage: c.Cmd.Usage})
12621290 if err != nil {
12631291 return c.fail(protocol.ExitUsage, "%v", err)
12641292 }
@@ -1267,10 +1295,25 @@ func runMRMerge(c *Ctx, args []string) int {
12671295 if !valid[strategy] {
12681296 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
12691297 }
1298 if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) {
1299 return c.fail(protocol.ExitUsage, "--cancel takes no other flag")
1300 }
12701301 repo, mr, code := mrRef(c, rest, policy.CanWrite)
12711302 if code >= 0 {
12721303 return code
12731304 }
1305 switch {
1306 case f.Has("--cancel"):
1307 return cancelQueuedMerge(c, repo, mr)
1308 case f.Has("--when-ready"):
1309 return queueMerge(c, repo, mr, strategy)
1310 }
1311 return mergeMR(c, repo, mr, strategy)
1312}
1313
1314// mergeMR merges mr now as c.User, or refuses saying why. The queue runs
1315// it too, as the user who queued the merge.
1316func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
12741317 if code := refuseArchived(c, repo); code >= 0 {
12751318 return code
12761319 }
@@ -1324,6 +1367,11 @@ func runMRMerge(c *Ctx, args []string) int {
13241367 // rebase when fast-forward is already possible IS a fast-forward
13251368 // (nothing is rewritten), so it stays legal.
13261369 if repo.Settings.RequireSignedCommits {
1370 if c.Source == queueSource && !ffPossible {
1371 return c.fail(protocol.ExitDenied,
1372 "%s is behind %s and %s requires signed commits, so the server cannot rebase it; rebase and push, and the merge stays queued",
1373 mr.SourceRef, mr.TargetRef, repo.Path())
1374 }
13271375 if strategy == "merge" || strategy == "squash" || !ffPossible {
13281376 return c.fail(protocol.ExitDenied,
13291377 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
@@ -1819,6 +1867,9 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
18191867 }
18201868 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft",
18211869 fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft))
1870 if !draft {
1871 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1872 }
18221873 // Marking ready is the request for review; going back to draft
18231874 // withdraws it and is not worth anyone's inbox.
18241875 //
internal/control/status.go +1
@@ -98,6 +98,7 @@ func runStatusSet(c *Ctx, args []string) int {
9898 }
9999 c.Store.RecordEvent(repo.ID, c.User.ID, "status",
100100 fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state))
101 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, full)
101102 return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) {
102103 fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state)
103104 })
internal/hookd/hookd.go +8
@@ -336,6 +336,9 @@ func (s *Server) postReceive(req Request) {
336336 if mr.State == "open" {
337337 s.st.SetMRState(mr.ID, "source_gone")
338338 }
339 if mr.QueuedAt != "" {
340 control.TryQueuedMerge(s.st, s.cfg, mr.ID) // dequeues: the source is gone
341 }
339342 continue // head ref retained: the diff stays viewable
340343 }
341344 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
@@ -362,6 +365,11 @@ func (s *Server) postReceive(req Request) {
362365 if mr.State == "source_gone" {
363366 s.st.SetMRState(mr.ID, "open") // branch came back
364367 }
368 // A queued merge stays queued across a push by someone who can
369 // merge it, and the new head has to pass the gates on its own.
370 if mr.QueuedAt != "" {
371 control.QueuedMergePushed(s.st, s.cfg, mr.ID, req.UserID, req.Scope)
372 }
365373 }
366374 }
367375}
internal/hookd/mergequeue_test.go added +286
@@ -0,0 +1,286 @@
1package hookd
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/control"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// A push to the source branch of a queued merge request tries the merge
19// again: a fast-forward merge queued while the branch was behind lands
20// once the rebased branch is pushed.
21func TestPostReceiveTriesQueuedMerge(t *testing.T) {
22 st, err := store.Open(":memory:")
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { st.Close() })
27 if err := st.MigrateUp(); err != nil {
28 t.Fatal(err)
29 }
30 uid, err := st.CreateUser("alice", false)
31 if err != nil {
32 t.Fatal(err)
33 }
34 repoID, err := st.CreateRepo("user", uid, "app", "public")
35 if err != nil {
36 t.Fatal(err)
37 }
38 repo, err := st.RepoByID(repoID)
39 if err != nil {
40 t.Fatal(err)
41 }
42 root := t.TempDir()
43 f := &shapeFixture{t: t, st: st, repo: repo, uid: uid, root: root, src: filepath.Join(root, "src")}
44 f.dir = control.RepoDir(root, repo.OwnerName, repo.Name)
45 cfg := config.Config{}
46 cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test"
47 srv := &Server{cfg: cfg, st: st}
48
49 os.MkdirAll(f.src, 0o755)
50 f.git(root, "init", "-q", "-b", "main", "src")
51 f.write("README", "x\n")
52 f.git(f.src, "add", ".")
53 f.git(f.src, "commit", "-q", "-m", "base")
54 f.git(f.src, "checkout", "-q", "-b", "feature")
55 f.write("feature.txt", "y\n")
56 f.git(f.src, "add", ".")
57 f.git(f.src, "commit", "-q", "-m", "change")
58 head := f.sha("HEAD")
59 f.git(f.src, "checkout", "-q", "main")
60 f.write("other.txt", "o\n")
61 f.git(f.src, "add", ".")
62 f.git(f.src, "commit", "-q", "-m", "target moves")
63 os.MkdirAll(filepath.Dir(f.dir), 0o755)
64 f.git(root, "init", "-q", "--bare", f.dir)
65 f.sync()
66 f.git(f.dir, "update-ref", "refs/merge-requests/1/head", head)
67 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", head, "md", false); err != nil {
68 t.Fatal(err)
69 }
70
71 var out, errOut bytes.Buffer
72 c := &control.Ctx{User: store.User{ID: uid, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
73 if code := control.Dispatch(c, []string{"mr", "merge", repo.Path(), "1", "--when-ready", "--strategy", "ff"}); code != protocol.ExitOK {
74 t.Fatalf("mr merge --when-ready: exit %d, %s", code, errOut.String())
75 }
76 if mr, _ := st.MRByNumber(repo.ID, 1); mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, "fast-forward not possible") {
77 t.Fatalf("queued MR = %+v, want it waiting on a fast-forward", mr)
78 }
79
80 f.git(f.src, "checkout", "-q", "feature")
81 f.git(f.src, "rebase", "-q", "main")
82 rebased := f.sha("HEAD")
83 f.sync()
84 srv.postReceive(Request{RepoID: repo.ID, UserID: uid, Scope: "full", Updates: []policy.RefUpdate{
85 {Ref: "refs/heads/feature", Old: head, New: rebased, IsForce: true}}})
86
87 mr, err := st.MRByNumber(repo.ID, 1)
88 if err != nil {
89 t.Fatal(err)
90 }
91 if mr.State != "merged" || mr.MergedBy != "alice" || mr.QueuedAt != "" {
92 t.Fatalf("MR after push = %s by %q queued %q (%s), want merged by alice", mr.State, mr.MergedBy, mr.QueuedAt, mr.QueueReason)
93 }
94 if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != rebased {
95 t.Fatalf("main = %s, want %s", main, rebased)
96 }
97}
98
99// A fork author who cannot write to the target pushes to the source of a
100// merge request someone else queued: the queue is not theirs to use, so
101// it is dequeued rather than tried. Deleting the source branch dequeues
102// it too.
103func TestPostReceiveDequeuesQueuedMerge(t *testing.T) {
104 st, err := store.Open(":memory:")
105 if err != nil {
106 t.Fatal(err)
107 }
108 t.Cleanup(func() { st.Close() })
109 if err := st.MigrateUp(); err != nil {
110 t.Fatal(err)
111 }
112 alice, _ := st.CreateUser("alice", false)
113 bob, _ := st.CreateUser("bob", false)
114 targetID, _ := st.CreateRepo("user", alice, "app", "public")
115 forkID, _ := st.CreateRepo("user", bob, "app", "public")
116 target, _ := st.RepoByID(targetID)
117 fork, _ := st.RepoByID(forkID)
118 if _, err := st.UpdateRepoSettings(target.ID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }); err != nil {
119 t.Fatal(err)
120 }
121 root := t.TempDir()
122 f := &shapeFixture{t: t, st: st, repo: fork, uid: bob, root: root, src: filepath.Join(root, "src")}
123 cfg := config.Config{}
124 cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test"
125 srv := &Server{cfg: cfg, st: st}
126
127 os.MkdirAll(f.src, 0o755)
128 f.git(root, "init", "-q", "-b", "main", "src")
129 f.write("README", "x\n")
130 f.git(f.src, "add", ".")
131 f.git(f.src, "commit", "-q", "-m", "base")
132 f.git(f.src, "checkout", "-q", "-b", "feature")
133 f.write("feature.txt", "y\n")
134 f.git(f.src, "add", ".")
135 f.git(f.src, "commit", "-q", "-m", "change")
136 head := f.sha("HEAD")
137 for _, r := range []store.Repo{target, fork} {
138 dir := control.RepoDir(root, r.OwnerName, r.Name)
139 os.MkdirAll(filepath.Dir(dir), 0o755)
140 f.git(root, "init", "-q", "--bare", dir)
141 f.git(f.src, "push", "-q", dir, "main", "feature")
142 }
143 targetDir := control.RepoDir(root, target.OwnerName, target.Name)
144 f.git(targetDir, "update-ref", "refs/merge-requests/1/head", head)
145 f.git(targetDir, "update-ref", "refs/merge-requests/2/head", head)
146 for range 2 {
147 if _, err := st.CreateMR(target.ID, bob, fork.ID, "feature", "main", "t", "", head, "md", false); err != nil {
148 t.Fatal(err)
149 }
150 }
151 for _, n := range []string{"1", "2"} {
152 var out, errOut bytes.Buffer
153 c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
154 if code := control.Dispatch(c, []string{"mr", "merge", target.Path(), n, "--when-ready"}); code != protocol.ExitOK {
155 t.Fatalf("queue !%s: exit %d, %s", n, code, errOut.String())
156 }
157 }
158 systemSays := func(n int64, want string) {
159 t.Helper()
160 mr, err := st.MRByNumber(target.ID, n)
161 if err != nil {
162 t.Fatal(err)
163 }
164 if mr.QueuedAt != "" || mr.State == "merged" {
165 t.Fatalf("!%d = state %s queued_at %q, want dequeued and not merged", n, mr.State, mr.QueuedAt)
166 }
167 cs, _ := st.ListMRComments(mr.ID)
168 for _, c := range cs {
169 if c.Kind == "system" && strings.Contains(c.Body, want) {
170 return
171 }
172 }
173 t.Fatalf("!%d timeline does not say %q: %+v", n, want, cs)
174 }
175
176 // !2 is closed first so the push reaches only !1.
177 st.MarkClosed(mustMR(t, st, target.ID, 2).ID, alice, "")
178 f.write("feature.txt", "z\n")
179 f.git(f.src, "commit", "-q", "-am", "more")
180 pushed := f.sha("HEAD")
181 forkDir := control.RepoDir(root, fork.OwnerName, fork.Name)
182 f.git(f.src, "push", "-q", forkDir, "feature")
183 srv.postReceive(Request{RepoID: fork.ID, UserID: bob, Scope: "full", Updates: []policy.RefUpdate{
184 {Ref: "refs/heads/feature", Old: head, New: pushed}}})
185 systemSays(1, "bob pushed and cannot merge into alice/app")
186
187 // Deleting the source dequeues a queued request.
188 st.SetMRState(mustMR(t, st, target.ID, 2).ID, "open")
189 var out, errOut bytes.Buffer
190 c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
191 if code := control.Dispatch(c, []string{"mr", "merge", target.Path(), "2", "--when-ready"}); code != protocol.ExitOK {
192 t.Fatalf("queue !2: exit %d, %s", code, errOut.String())
193 }
194 f.git(forkDir, "update-ref", "-d", "refs/heads/feature")
195 srv.postReceive(Request{RepoID: fork.ID, UserID: bob, Scope: "full", Updates: []policy.RefUpdate{
196 {Ref: "refs/heads/feature", Old: pushed, New: zeroSHA40, IsDelete: true}}})
197 systemSays(2, "the source branch was deleted")
198}
199
200func mustMR(t *testing.T, st *store.Store, repoID, n int64) store.MR {
201 t.Helper()
202 mr, err := st.MRByNumber(repoID, n)
203 if err != nil {
204 t.Fatal(err)
205 }
206 return mr
207}
208
209// A push the queue cannot attribute to a writer dequeues: one from a
210// write deploy key, though the account that registered it can write, and
211// one whose pusher cannot be looked up.
212func TestPostReceiveDequeuesUncheckedPush(t *testing.T) {
213 for _, tc := range []struct {
214 name string
215 user func(alice int64) int64
216 scope func(repoID int64) string
217 reason string
218 }{
219 {"deploy key", func(a int64) int64 { return a }, func(id int64) string { return fmt.Sprintf("deploy:%d:rw", id) },
220 "a deploy key pushed, and a deploy key cannot merge"},
221 {"unknown pusher", func(int64) int64 { return 9999 }, func(int64) string { return "full" },
222 "could not check who pushed"},
223 } {
224 t.Run(tc.name, func(t *testing.T) {
225 st, err := store.Open(":memory:")
226 if err != nil {
227 t.Fatal(err)
228 }
229 t.Cleanup(func() { st.Close() })
230 if err := st.MigrateUp(); err != nil {
231 t.Fatal(err)
232 }
233 alice, _ := st.CreateUser("alice", false)
234 repoID, _ := st.CreateRepo("user", alice, "app", "public")
235 st.UpdateRepoSettings(repoID, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
236 repo, _ := st.RepoByID(repoID)
237 root := t.TempDir()
238 f := &shapeFixture{t: t, st: st, repo: repo, uid: alice, root: root, src: filepath.Join(root, "src")}
239 f.dir = control.RepoDir(root, repo.OwnerName, repo.Name)
240 cfg := config.Config{}
241 cfg.Server.Root = root
242 srv := &Server{cfg: cfg, st: st}
243 os.MkdirAll(f.src, 0o755)
244 f.git(root, "init", "-q", "-b", "main", "src")
245 f.write("README", "x\n")
246 f.git(f.src, "add", ".")
247 f.git(f.src, "commit", "-q", "-m", "base")
248 f.git(f.src, "checkout", "-q", "-b", "feature")
249 f.write("feature.txt", "y\n")
250 f.git(f.src, "add", ".")
251 f.git(f.src, "commit", "-q", "-m", "change")
252 head := f.sha("HEAD")
253 os.MkdirAll(filepath.Dir(f.dir), 0o755)
254 f.git(root, "init", "-q", "--bare", f.dir)
255 f.sync()
256 f.git(f.dir, "update-ref", "refs/merge-requests/1/head", head)
257 if _, err := st.CreateMR(repo.ID, alice, repo.ID, "feature", "main", "t", "", head, "md", false); err != nil {
258 t.Fatal(err)
259 }
260 var out, errOut bytes.Buffer
261 c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
262 if code := control.Dispatch(c, []string{"mr", "merge", repo.Path(), "1", "--when-ready"}); code != protocol.ExitOK {
263 t.Fatalf("queue: exit %d, %s", code, errOut.String())
264 }
265
266 f.write("feature.txt", "z\n")
267 f.git(f.src, "commit", "-q", "-am", "more")
268 pushed := f.sha("HEAD")
269 f.sync()
270 srv.postReceive(Request{RepoID: repo.ID, UserID: tc.user(alice), Scope: tc.scope(repo.ID),
271 Updates: []policy.RefUpdate{{Ref: "refs/heads/feature", Old: head, New: pushed}}})
272
273 mr := mustMR(t, st, repo.ID, 1)
274 if mr.QueuedAt != "" || mr.State != "open" {
275 t.Fatalf("!1 = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt)
276 }
277 cs, _ := st.ListMRComments(mr.ID)
278 for _, c := range cs {
279 if c.Kind == "system" && strings.Contains(c.Body, tc.reason) {
280 return
281 }
282 }
283 t.Fatalf("timeline does not say %q: %+v", tc.reason, cs)
284 })
285 }
286}
internal/httpd/mractions.go +9 −2
@@ -78,8 +78,15 @@ func (s *Server) mrLabelSubmit(w http.ResponseWriter, r *http.Request, u store.U
7878
7979func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
8080 args := []string{}
81 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" {
82 args = append(args, "--strategy", st)
81 if r.FormValue("cancel") == "on" {
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 }
8390 }
8491 _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...))
8592 s.done(w, r, code, msg, s.mrRedirect)
internal/store/dashboard.go +10 −6
@@ -10,6 +10,7 @@ type DashboardItem struct {
1010 Author string
1111 State string
1212 UpdatedAt string
13 Queued bool // a merge request with a queued merge
1314}
1415
1516// reachableCond filters to repositories the user owns, is granted on, or
@@ -39,7 +40,7 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error)
3940 var out []DashboardItem
4041 for rows.Next() {
4142 var d DashboardItem
42 if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt); err != nil {
43 if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt, &d.Queued); err != nil {
4344 return nil, err
4445 }
4546 out = append(out, d)
@@ -51,7 +52,8 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error)
5152// each still walks the 0035 index that supplies its ORDER BY.
5253const dashboardMRsQuery = `
5354 SELECT COALESCE(u.username, o.name) || '/' || r.name,
54 x.number, x.title, au.username, x.state, x.updated_at
55 x.number, x.title, au.username, x.state, x.updated_at,
56 EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id)
5557 FROM merge_requests x
5658 JOIN repos r ON r.id = x.repo_id
5759 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -69,7 +71,7 @@ func (s *Store) DashboardMRs(userID int64) ([]DashboardItem, error) {
6971// DashboardIssues is the issue counterpart of DashboardMRs.
7072const dashboardIssuesQuery = `
7173 SELECT COALESCE(u.username, o.name) || '/' || r.name,
72 x.number, x.title, au.username, x.state, x.updated_at
74 x.number, x.title, au.username, x.state, x.updated_at, 0
7375 FROM issues x
7476 JOIN repos r ON r.id = x.repo_id
7577 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -136,7 +138,8 @@ func (s *Store) PinnedRepos(userID int64) ([]Repo, error) {
136138// current head.
137139const reviewQueueQuery = `
138140 SELECT COALESCE(u.username, o.name) || '/' || r.name,
139 x.number, x.title, au.username, x.state, x.updated_at
141 x.number, x.title, au.username, x.state, x.updated_at,
142 EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id)
140143 FROM merge_requests x
141144 JOIN repos r ON r.id = x.repo_id
142145 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -159,7 +162,8 @@ const reviewQueueQuery = `
159162// merge_requests table is the whole instance.
160163const requestedReviewsQuery = `
161164 SELECT COALESCE(u.username, o.name) || '/' || r.name,
162 x.number, x.title, au.username, x.state, x.updated_at
165 x.number, x.title, au.username, x.state, x.updated_at,
166 EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id)
163167 FROM mr_review_requests rr
164168 JOIN merge_requests x ON x.id = rr.mr_id
165169 JOIN repos r ON r.id = x.repo_id
@@ -233,7 +237,7 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) {
233237// is the whole instance.
234238const assignedIssuesQuery = `
235239 SELECT COALESCE(u.username, o.name) || '/' || r.name,
236 x.number, x.title, au.username, x.state, x.updated_at
240 x.number, x.title, au.username, x.state, x.updated_at, 0
237241 FROM issue_assignees ia
238242 JOIN issues x ON x.id = ia.issue_id
239243 JOIN repos r ON r.id = x.repo_id
internal/store/mergequeue.go added +99
@@ -0,0 +1,99 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// MRByID loads a merge request by its row id, without labels or review
9// requests.
10func (s *Store) MRByID(id int64) (MR, error) {
11 m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.id = ?", id))
12 if errors.Is(err, sql.ErrNoRows) {
13 return m, ErrNotFound
14 }
15 return m, err
16}
17
18// QueueMerge queues a merge request to merge as userID with strategy
19// ("" for the default) once its gates pass, bound to the SSH key or API
20// token it was queued with (both 0 for a web session). Queueing again
21// replaces the queuer, strategy and credential and clears the reason.
22func (s *Store) QueueMerge(mrID, userID int64, strategy string, keyID, tokenID int64) error {
23 credential := ""
24 switch {
25 case keyID != 0:
26 credential = "key"
27 case tokenID != 0:
28 credential = "token"
29 }
30 _, err := s.DB.Exec(`
31 INSERT INTO mr_merge_queue (mr_id, user_id, strategy, credential, key_id, token_id)
32 VALUES (?, ?, ?, ?, ?, ?)
33 ON CONFLICT (mr_id) DO UPDATE SET user_id = excluded.user_id,
34 strategy = excluded.strategy, credential = excluded.credential,
35 key_id = excluded.key_id, token_id = excluded.token_id, reason = '',
36 queued_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
37 mrID, userID, strategy, credential, nullID(keyID), nullID(tokenID))
38 return err
39}
40
41// QueueCredential is the credential a queued merge was queued with. Kind
42// is "key", "token", or "" for a web session; an id of 0 under "key" or
43// "token" means that credential has since been removed.
44type QueueCredential struct {
45 Kind string
46 KeyID int64
47 TokenID int64
48}
49
50func (s *Store) MergeQueueCredential(mrID int64) (QueueCredential, error) {
51 var q QueueCredential
52 err := s.DB.QueryRow(
53 "SELECT credential, COALESCE(key_id, 0), COALESCE(token_id, 0) FROM mr_merge_queue WHERE mr_id = ?",
54 mrID).Scan(&q.Kind, &q.KeyID, &q.TokenID)
55 if errors.Is(err, sql.ErrNoRows) {
56 return q, ErrNotFound
57 }
58 return q, err
59}
60
61// DequeueMerge takes a merge request off the queue, reporting whether it
62// was on it.
63func (s *Store) DequeueMerge(mrID int64) (bool, error) {
64 res, err := s.DB.Exec("DELETE FROM mr_merge_queue WHERE mr_id = ?", mrID)
65 if err != nil {
66 return false, err
67 }
68 n, err := res.RowsAffected()
69 return n > 0, err
70}
71
72// SetMergeQueueReason records why the last attempt at a queued merge did
73// not merge.
74func (s *Store) SetMergeQueueReason(mrID int64, reason string) error {
75 _, err := s.DB.Exec("UPDATE mr_merge_queue SET reason = ? WHERE mr_id = ?", reason, mrID)
76 return err
77}
78
79// QueuedMRsAtHead is the queued, open merge requests of a repository
80// whose head is sha: the ones a status reported on sha can move.
81func (s *Store) QueuedMRsAtHead(repoID int64, sha string) ([]int64, error) {
82 rows, err := s.DB.Query(`
83 SELECT m.id FROM mr_merge_queue q JOIN merge_requests m ON m.id = q.mr_id
84 WHERE m.repo_id = ? AND m.head_sha = ? AND m.state = 'open'
85 ORDER BY m.number`, repoID, sha)
86 if err != nil {
87 return nil, err
88 }
89 defer rows.Close()
90 var ids []int64
91 for rows.Next() {
92 var id int64
93 if err := rows.Scan(&id); err != nil {
94 return nil, err
95 }
96 ids = append(ids, id)
97 }
98 return ids, rows.Err()
99}
internal/store/mergequeue_test.go added +136
@@ -0,0 +1,136 @@
1package store
2
3import (
4 "slices"
5 "testing"
6)
7
8// A queued merge rides on the merge request row, re-queueing replaces
9// the queuer and strategy and clears the reason, and dequeueing says
10// whether there was anything to take off.
11func TestMergeQueueRoundTrip(t *testing.T) {
12 s, repoID, uid := mrFixture(t)
13 mr, err := s.MRByNumber(repoID, 1)
14 if err != nil {
15 t.Fatal(err)
16 }
17 if mr.QueuedAt != "" {
18 t.Fatalf("fresh MR is queued: %+v", mr)
19 }
20 if err := s.QueueMerge(mr.ID, uid, "ff", 0, 0); err != nil {
21 t.Fatal(err)
22 }
23 if err := s.SetMergeQueueReason(mr.ID, "checks pending"); err != nil {
24 t.Fatal(err)
25 }
26 mr, _ = s.MRByNumber(repoID, 1)
27 if mr.QueuedAt == "" || mr.QueuedBy != "cmc" || mr.QueuedByID != uid || mr.QueueStrategy != "ff" || mr.QueueReason != "checks pending" {
28 t.Fatalf("queued MR: %+v", mr)
29 }
30 byID, err := s.MRByID(mr.ID)
31 if err != nil || byID.Number != 1 || byID.QueueStrategy != "ff" {
32 t.Fatalf("MRByID = %+v, %v", byID, err)
33 }
34
35 if err := s.QueueMerge(mr.ID, uid, "merge", 0, 0); err != nil {
36 t.Fatal(err)
37 }
38 mr, _ = s.MRByNumber(repoID, 1)
39 if mr.QueueStrategy != "merge" || mr.QueueReason != "" {
40 t.Fatalf("re-queued MR: %+v", mr)
41 }
42
43 ids, err := s.QueuedMRsAtHead(repoID, "abc123")
44 if err != nil || !slices.Equal(ids, []int64{mr.ID}) {
45 t.Fatalf("QueuedMRsAtHead = %v, %v", ids, err)
46 }
47 if ids, _ := s.QueuedMRsAtHead(repoID, "other"); len(ids) != 0 {
48 t.Fatalf("QueuedMRsAtHead(other) = %v", ids)
49 }
50
51 if ok, err := s.DequeueMerge(mr.ID); err != nil || !ok {
52 t.Fatalf("DequeueMerge = %v, %v", ok, err)
53 }
54 if ok, _ := s.DequeueMerge(mr.ID); ok {
55 t.Fatal("second DequeueMerge found a row")
56 }
57}
58
59// A merge request that leaves the open states leaves the queue with it,
60// whichever path merged or closed it.
61func TestMergeQueueLeftOnMergeOrClose(t *testing.T) {
62 s, repoID, uid := mrFixture(t)
63 mr, _ := s.MRByNumber(repoID, 1)
64 for _, mark := range []func() error{
65 func() error { return s.MarkMerged(mr.ID, "base", uid, "") },
66 func() error { return s.MarkClosed(mr.ID, uid, "") },
67 } {
68 if err := s.SetMRState(mr.ID, "open"); err != nil {
69 t.Fatal(err)
70 }
71 if err := s.QueueMerge(mr.ID, uid, "", 0, 0); err != nil {
72 t.Fatal(err)
73 }
74 if err := mark(); err != nil {
75 t.Fatal(err)
76 }
77 got, _ := s.MRByNumber(repoID, 1)
78 if got.QueuedAt != "" {
79 t.Fatalf("%s MR still queued: %+v", got.State, got)
80 }
81 }
82 // source_gone keeps it: the branch can come back.
83 if err := s.SetMRState(mr.ID, "open"); err != nil {
84 t.Fatal(err)
85 }
86 s.QueueMerge(mr.ID, uid, "", 0, 0)
87 s.SetMRState(mr.ID, "source_gone")
88 if got, _ := s.MRByNumber(repoID, 1); got.QueuedAt == "" {
89 t.Fatal("source_gone dropped the queued merge")
90 }
91}
92
93// A queued merge remembers the credential it was queued with, and a
94// removed key or revoked token reads back as gone rather than as some
95// later credential that reused the id.
96func TestMergeQueueCredential(t *testing.T) {
97 s, repoID, uid := mrFixture(t)
98 mr, _ := s.MRByNumber(repoID, 1)
99 if err := s.AddSSHKey(uid, "SHA256:k1", "ssh-ed25519", []byte("b1"), "full", ""); err != nil {
100 t.Fatal(err)
101 }
102 key, err := s.SSHKeyByFingerprint("SHA256:k1")
103 if err != nil {
104 t.Fatal(err)
105 }
106 if err := s.QueueMerge(mr.ID, uid, "", key.ID, 0); err != nil {
107 t.Fatal(err)
108 }
109 if q, err := s.MergeQueueCredential(mr.ID); err != nil || q.Kind != "key" || q.KeyID != key.ID {
110 t.Fatalf("credential = %+v, %v", q, err)
111 }
112 if err := s.RemoveSSHKey(uid, "SHA256:k1"); err != nil {
113 t.Fatal(err)
114 }
115 if q, _ := s.MergeQueueCredential(mr.ID); q.Kind != "key" || q.KeyID != 0 {
116 t.Fatalf("credential after key removal = %+v, want key with id 0", q)
117 }
118 if err := s.QueueMerge(mr.ID, uid, "", 0, 0); err != nil {
119 t.Fatal(err)
120 }
121 if q, _ := s.MergeQueueCredential(mr.ID); q.Kind != "" {
122 t.Fatalf("web-queued credential = %+v", q)
123 }
124}
125
126// A merge request whose source branch is gone is not one a status can
127// merge.
128func TestQueuedMRsAtHeadSkipsSourceGone(t *testing.T) {
129 s, repoID, uid := mrFixture(t)
130 mr, _ := s.MRByNumber(repoID, 1)
131 s.QueueMerge(mr.ID, uid, "", 0, 0)
132 s.SetMRState(mr.ID, "source_gone")
133 if ids, _ := s.QueuedMRsAtHead(repoID, "abc123"); len(ids) != 0 {
134 t.Fatalf("QueuedMRsAtHead = %v, want none", ids)
135 }
136}
internal/store/migrations/0067_merge_queue.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TRIGGER mr_merge_queue_leave;
2DROP TABLE mr_merge_queue;
internal/store/migrations/0067_merge_queue.up.sql added +25
@@ -0,0 +1,25 @@
1-- A merge request queued with `mr merge --when-ready`: merged as user_id
2-- with strategy once its gates pass. reason is why the last attempt did
3-- not merge. A merge request that is merged or closed leaves the queue,
4-- whichever path did it.
5--
6-- credential is what the merge was queued with: 'key' (key_id) or
7-- 'token' (token_id), or '' for a web session, which binds to the
8-- account alone. A removed key or revoked token nulls its id, so the id
9-- of a later credential never stands in for it.
10CREATE TABLE mr_merge_queue (
11 mr_id INTEGER PRIMARY KEY REFERENCES merge_requests(id) ON DELETE CASCADE,
12 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
13 strategy TEXT NOT NULL DEFAULT '',
14 credential TEXT NOT NULL DEFAULT '' CHECK (credential IN ('', 'key', 'token')),
15 key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
16 token_id INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL,
17 reason TEXT NOT NULL DEFAULT '',
18 queued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
19);
20CREATE INDEX mr_merge_queue_user ON mr_merge_queue(user_id);
21CREATE TRIGGER mr_merge_queue_leave AFTER UPDATE OF state ON merge_requests
22WHEN NEW.state IN ('merged', 'closed')
23BEGIN
24 DELETE FROM mr_merge_queue WHERE mr_id = NEW.id;
25END;
internal/store/mrs.go +15 −3
@@ -38,6 +38,13 @@ type MR struct {
3838 // ReviewRequests is who has been asked, directly, for a review — the
3939 // mr review request counterpart of Issue.Assignees.
4040 ReviewRequests []string
41 // The queued merge (mr merge --when-ready); QueuedAt is "" when there
42 // is none. QueueReason is why the last attempt did not merge.
43 QueuedByID int64
44 QueuedBy string
45 QueueStrategy string
46 QueueReason string
47 QueuedAt string
4148}
4249
4350type MRReview struct {
@@ -96,7 +103,9 @@ const mrSelect = `
96103 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
97104 COALESCE(ms.title, ''), m.head_sha,
98105 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
99 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at
106 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at,
107 COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''),
108 COALESCE(q.reason, ''), COALESCE(q.queued_at, '')
100109 FROM merge_requests m
101110 JOIN users u ON u.id = m.author_id
102111 LEFT JOIN users mu ON mu.id = m.merged_by
@@ -104,13 +113,16 @@ const mrSelect = `
104113 LEFT JOIN repos sr ON sr.id = m.source_repo_id
105114 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
106115 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id
107 LEFT JOIN milestones ms ON ms.id = m.milestone_id`
116 LEFT JOIN milestones ms ON ms.id = m.milestone_id
117 LEFT JOIN mr_merge_queue q ON q.mr_id = m.id
118 LEFT JOIN users qu ON qu.id = q.user_id`
108119
109120func scanMR(row interface{ Scan(...any) error }) (MR, error) {
110121 var m MR
111122 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
112123 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
113 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt)
124 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt,
125 &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt)
114126 return m, err
115127}
116128
internal/store/tokens.go +13
@@ -64,6 +64,19 @@ func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
6464 return u, t, err
6565}
6666
67// APITokenByID loads a token by id, expired or not.
68func (s *Store) APITokenByID(id int64) (APIToken, error) {
69 var t APIToken
70 var exp sql.NullString
71 err := s.DB.QueryRow("SELECT id, name, scope, created_at, expires_at FROM api_tokens WHERE id = ?", id).
72 Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp)
73 if errors.Is(err, sql.ErrNoRows) {
74 return t, ErrNotFound
75 }
76 t.ExpiresAt = parseTime(exp)
77 return t, err
78}
79
6780func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
6881 rows, err := s.DB.Query(`
6982 SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
internal/web/templates/mr.html +7
@@ -95,6 +95,12 @@
9595 <div class="grp">
9696 <h2>Merge</h2>
9797 {{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}}
98104 <form method="post" action="{{$base}}/merge" class="actions">
99105 <label class="none" for="strategy">Strategy</label>
100106 <select id="strategy" name="strategy">
@@ -105,6 +111,7 @@
105111 <option value="rebase">Rebase</option>
106112 </select>
107113 <button type="submit">Merge</button>
114 <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button>
108115 </form>
109116 <form method="post" action="{{$base}}/close" class="actions">
110117 <label class="vh" for="by">Closed in favour of</label>