internal/control/mergequeue_test.go

v1.41.0
gitbay/internal/control/mergequeue_test.go history · blame · raw

538 lines · 20088 bytes

  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, 0, "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}