internal/control/mergequeue_test.go
538 lines · 20085 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, "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}