internal/control/build_test.go
667 lines · 25685 bytes
1package control
2
3import (
4 "bytes"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16const testZeroSHA = "0000000000000000000000000000000000000000"
17
18// gitTestEnv sets up an isolated git identity so tests never touch a
19// developer's real config.
20func gitTestEnv() []string {
21 return append(os.Environ(),
22 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
23 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
24 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
25}
26
27func gitRunner(t *testing.T) func(dir string, args ...string) string {
28 t.Helper()
29 env := gitTestEnv()
30 return func(dir string, args ...string) string {
31 t.Helper()
32 cmd := exec.Command("git", args...)
33 cmd.Dir = dir
34 cmd.Env = env
35 out, err := cmd.CombinedOutput()
36 if err != nil {
37 t.Fatalf("git %v: %v\n%s", args, err, out)
38 }
39 return string(out)
40 }
41}
42
43// newQueueTestRepo returns a store with one public repo (default branch
44// "main", matching the schema default) and the uid to queue builds as.
45func newQueueTestRepo(t *testing.T) (*store.Store, store.Repo, int64) {
46 t.Helper()
47 st, err := store.Open(":memory:")
48 if err != nil {
49 t.Fatal(err)
50 }
51 t.Cleanup(func() { st.Close() })
52 if err := st.MigrateUp(); err != nil {
53 t.Fatal(err)
54 }
55 uid, err := st.CreateUser("alice", false)
56 if err != nil {
57 t.Fatal(err)
58 }
59 repoID, err := st.CreateRepo("user", uid, "app", "public")
60 if err != nil {
61 t.Fatal(err)
62 }
63 repo, err := st.RepoByID(repoID)
64 if err != nil {
65 t.Fatal(err)
66 }
67 return st, repo, uid
68}
69
70// A DiffFiles failure must not turn into a silent skip: when the old sha
71// on record cannot be diffed against, every job runs regardless of what
72// it names in paths, the same as when there is no diff base at all.
73func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) {
74 st, repo, uid := newQueueTestRepo(t)
75 git := gitRunner(t)
76
77 root := t.TempDir()
78
79 // A job whose paths would exclude a docs-only change, so the test
80 // proves something: without fail-open, the diff failure would leave
81 // the filter unevaluated and this build would never queue.
82 src := filepath.Join(root, "src")
83 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
84 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
85 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
86 "jobs:\n unit:\n paths:\n - src/**\n steps:\n - echo hi\n"), 0o644)
87 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
88 git(root, "init", "-q", "-b", "main", "src")
89 git(src, "add", ".")
90 git(src, "commit", "-q", "-m", "base")
91 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
92 git(src, "add", ".")
93 git(src, "commit", "-q", "-m", "docs only")
94 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
95
96 dir := RepoDir(root, repo.OwnerName, repo.Name)
97 os.MkdirAll(filepath.Dir(dir), 0o755)
98 git(root, "clone", "-q", "--bare", src, dir)
99
100 // Well-formed but names no object in this repo: git diff itself
101 // fails, rather than the empty/all-zero short-circuit HasDiffBase
102 // already covers.
103 old := strings.Repeat("1", 40)
104
105 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", old, newSHA, time.Now())
106
107 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
108 if err != nil || len(builds) != 1 {
109 t.Fatalf("builds after queue: %v %v", builds, err)
110 }
111 if builds[0].Job != "unit" || builds[0].Status != "pending" || builds[0].SHA != newSHA {
112 t.Fatalf("queued build wrong: %+v", builds[0])
113 }
114}
115
116// A new branch's first push carries no old sha, but a diff base still
117// exists: the merge base with the default branch. A push whose commits
118// only touch paths a job ignores must not queue that job, or path
119// filters never do anything on the ordinary branch-then-MR workflow.
120func TestQueueBranchBuildsNewBranchIgnoredPathSkips(t *testing.T) {
121 st, repo, uid := newQueueTestRepo(t)
122 git := gitRunner(t)
123 root := t.TempDir()
124
125 src := filepath.Join(root, "src")
126 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
127 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
128 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
129 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
130 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
131 git(root, "init", "-q", "-b", "main", "src")
132 git(src, "add", ".")
133 git(src, "commit", "-q", "-m", "base")
134
135 git(src, "checkout", "-q", "-b", "feature")
136 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
137 git(src, "add", ".")
138 git(src, "commit", "-q", "-m", "docs only")
139 featureSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
140
141 dir := RepoDir(root, repo.OwnerName, repo.Name)
142 os.MkdirAll(filepath.Dir(dir), 0o755)
143 git(root, "clone", "-q", "--bare", src, dir)
144
145 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
146
147 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
148 if err != nil {
149 t.Fatal(err)
150 }
151 if len(builds) != 0 {
152 t.Fatalf("docs-only push on a new branch queued a build: %+v", builds)
153 }
154}
155
156// The same new-branch push, but touching a path the job cares about:
157// the merge-base diff must still let it through.
158func TestQueueBranchBuildsNewBranchMatchedPathQueues(t *testing.T) {
159 st, repo, uid := newQueueTestRepo(t)
160 git := gitRunner(t)
161 root := t.TempDir()
162
163 src := filepath.Join(root, "src")
164 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
165 os.MkdirAll(filepath.Join(src, "src"), 0o755)
166 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
167 "jobs:\n unit:\n paths:\n - src/**\n steps:\n - echo hi\n"), 0o644)
168 os.WriteFile(filepath.Join(src, "src", "x.go"), []byte("package x\n"), 0o644)
169 git(root, "init", "-q", "-b", "main", "src")
170 git(src, "add", ".")
171 git(src, "commit", "-q", "-m", "base")
172
173 git(src, "checkout", "-q", "-b", "feature")
174 os.WriteFile(filepath.Join(src, "src", "x.go"), []byte("package x\n\nvar y int\n"), 0o644)
175 git(src, "add", ".")
176 git(src, "commit", "-q", "-m", "src change")
177 featureSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
178
179 dir := RepoDir(root, repo.OwnerName, repo.Name)
180 os.MkdirAll(filepath.Dir(dir), 0o755)
181 git(root, "clone", "-q", "--bare", src, dir)
182
183 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
184
185 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
186 if err != nil || len(builds) != 1 {
187 t.Fatalf("builds after queue: %v %v", builds, err)
188 }
189 if builds[0].Job != "unit" || builds[0].SHA != featureSHA {
190 t.Fatalf("queued build wrong: %+v", builds[0])
191 }
192}
193
194// When the new branch shares no history with the default branch, the
195// merge base cannot be computed. That must fail open, same as any other
196// diff base that cannot be evaluated.
197func TestQueueBranchBuildsNewBranchFailsOpenWithoutMergeBase(t *testing.T) {
198 st, repo, uid := newQueueTestRepo(t)
199 git := gitRunner(t)
200 root := t.TempDir()
201
202 dir := RepoDir(root, repo.OwnerName, repo.Name)
203 os.MkdirAll(filepath.Dir(dir), 0o755)
204 git(root, "init", "-q", "--bare", dir)
205
206 mainSrc := filepath.Join(root, "main-src")
207 os.MkdirAll(filepath.Join(mainSrc, ".gitbay"), 0o755)
208 os.WriteFile(filepath.Join(mainSrc, ".gitbay", "ci.yml"), []byte(
209 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
210 git(root, "init", "-q", "-b", "main", "main-src")
211 git(mainSrc, "add", ".")
212 git(mainSrc, "commit", "-q", "-m", "base")
213 git(mainSrc, "push", "-q", dir, "main")
214
215 // An unrelated repository: no common commit with main.
216 otherSrc := filepath.Join(root, "other-src")
217 os.MkdirAll(filepath.Join(otherSrc, ".gitbay"), 0o755)
218 os.MkdirAll(filepath.Join(otherSrc, "docs"), 0o755)
219 os.WriteFile(filepath.Join(otherSrc, ".gitbay", "ci.yml"), []byte(
220 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
221 os.WriteFile(filepath.Join(otherSrc, "docs", "x.md"), []byte("# x\n"), 0o644)
222 git(root, "init", "-q", "-b", "feature", "other-src")
223 git(otherSrc, "add", ".")
224 git(otherSrc, "commit", "-q", "-m", "unrelated docs-only")
225 otherSHA := strings.TrimSpace(git(otherSrc, "rev-parse", "HEAD"))
226 git(otherSrc, "push", "-q", dir, "feature")
227
228 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, otherSHA, time.Now())
229
230 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
231 if err != nil || len(builds) != 1 {
232 t.Fatalf("expected fail-open to queue the job: %v %v", builds, err)
233 }
234}
235
236// The first push to a brand-new repository moves the default branch
237// itself with no prior commit: the merge base of the default branch
238// against its own tip is the tip, which carries no diff. That must
239// fail open rather than read as "nothing changed".
240func TestQueueBranchBuildsFreshDefaultBranchFailsOpen(t *testing.T) {
241 st, repo, uid := newQueueTestRepo(t)
242 git := gitRunner(t)
243 root := t.TempDir()
244
245 src := filepath.Join(root, "src")
246 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
247 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
248 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
249 "jobs:\n unit:\n paths:\n - src/**\n steps:\n - echo hi\n"), 0o644)
250 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
251 git(root, "init", "-q", "-b", "main", "src")
252 git(src, "add", ".")
253 git(src, "commit", "-q", "-m", "initial")
254 sha := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
255
256 dir := RepoDir(root, repo.OwnerName, repo.Name)
257 os.MkdirAll(filepath.Dir(dir), 0o755)
258 git(root, "clone", "-q", "--bare", src, dir)
259
260 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", testZeroSHA, sha, time.Now())
261
262 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
263 if err != nil || len(builds) != 1 {
264 t.Fatalf("expected fail-open on the repository's first commit: %v %v", builds, err)
265 }
266}
267
268// An ordinary push with a genuine old sha is unaffected by the
269// new-branch handling: filtering still works exactly as it did before.
270func TestQueueBranchBuildsOrdinaryPushStillFilters(t *testing.T) {
271 st, repo, uid := newQueueTestRepo(t)
272 git := gitRunner(t)
273 root := t.TempDir()
274
275 src := filepath.Join(root, "src")
276 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
277 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
278 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
279 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
280 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
281 git(root, "init", "-q", "-b", "main", "src")
282 git(src, "add", ".")
283 git(src, "commit", "-q", "-m", "base")
284 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
285
286 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
287 git(src, "add", ".")
288 git(src, "commit", "-q", "-m", "docs only")
289 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
290
291 dir := RepoDir(root, repo.OwnerName, repo.Name)
292 os.MkdirAll(filepath.Dir(dir), 0o755)
293 git(root, "clone", "-q", "--bare", src, dir)
294
295 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
296
297 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
298 if err != nil {
299 t.Fatal(err)
300 }
301 if len(builds) != 0 {
302 t.Fatalf("docs-only push with a real old sha queued a build: %+v", builds)
303 }
304}
305
306// A job a path filter excludes records a ci/<job> status of "skipped"
307// naming the reason, rather than leaving the commit with no status for
308// that job at all (#172).
309func TestQueueBranchBuildsFilteredJobRecordsSkippedStatus(t *testing.T) {
310 st, repo, uid := newQueueTestRepo(t)
311 git := gitRunner(t)
312 root := t.TempDir()
313
314 src := filepath.Join(root, "src")
315 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
316 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
317 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
318 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
319 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
320 git(root, "init", "-q", "-b", "main", "src")
321 git(src, "add", ".")
322 git(src, "commit", "-q", "-m", "base")
323 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
324
325 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
326 git(src, "add", ".")
327 git(src, "commit", "-q", "-m", "docs only")
328 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
329
330 dir := RepoDir(root, repo.OwnerName, repo.Name)
331 os.MkdirAll(filepath.Dir(dir), 0o755)
332 git(root, "clone", "-q", "--bare", src, dir)
333
334 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
335
336 statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
337 if err != nil {
338 t.Fatal(err)
339 }
340 if len(statuses) != 1 || statuses[0].Context != "ci/unit" {
341 t.Fatalf("statuses on %s: %+v", newSHA, statuses)
342 }
343 if statuses[0].State != "skipped" {
344 t.Fatalf("filtered job status: got %q, want skipped", statuses[0].State)
345 }
346 if statuses[0].Description == "" {
347 t.Fatal("skipped status carries no reason")
348 }
349}
350
351// A tag job and a scheduled job are not push jobs at all: neither records
352// a skipped status, filtered push or not. Only the ordinary job that a
353// path filter actually excluded does.
354func TestQueueBranchBuildsTagAndScheduledJobsRecordNoSkippedStatus(t *testing.T) {
355 st, repo, uid := newQueueTestRepo(t)
356 git := gitRunner(t)
357 root := t.TempDir()
358
359 src := filepath.Join(root, "src")
360 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
361 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
362 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(strings.Join([]string{
363 "jobs:",
364 " unit:",
365 " paths-ignore:",
366 " - docs/**",
367 " steps:",
368 " - echo hi",
369 " release:",
370 " tags: 'v*'",
371 " steps:",
372 " - echo release",
373 " nightly:",
374 " schedule: '0 0 * * *'",
375 " steps:",
376 " - echo nightly",
377 "",
378 }, "\n")), 0o644)
379 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
380 git(root, "init", "-q", "-b", "main", "src")
381 git(src, "add", ".")
382 git(src, "commit", "-q", "-m", "base")
383 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
384
385 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
386 git(src, "add", ".")
387 git(src, "commit", "-q", "-m", "docs only")
388 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
389
390 dir := RepoDir(root, repo.OwnerName, repo.Name)
391 os.MkdirAll(filepath.Dir(dir), 0o755)
392 git(root, "clone", "-q", "--bare", src, dir)
393
394 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
395
396 statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
397 if err != nil {
398 t.Fatal(err)
399 }
400 if len(statuses) != 1 || statuses[0].Context != "ci/unit" || statuses[0].State != "skipped" {
401 t.Fatalf("statuses on %s: %+v", newSHA, statuses)
402 }
403}
404
405// A job already built for this commit on another branch is a fact about
406// the commit, not something a filter excluded: it keeps whatever status
407// that build reported (or none, if the run is still queued elsewhere) and
408// must not be overwritten with "skipped".
409func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
410 st, repo, uid := newQueueTestRepo(t)
411 git := gitRunner(t)
412 root := t.TempDir()
413
414 src := filepath.Join(root, "src")
415 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
416 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
417 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
418 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
419 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
420 git(root, "init", "-q", "-b", "main", "src")
421 git(src, "add", ".")
422 git(src, "commit", "-q", "-m", "base")
423 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
424
425 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
426 git(src, "add", ".")
427 git(src, "commit", "-q", "-m", "docs only")
428 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
429
430 dir := RepoDir(root, repo.OwnerName, repo.Name)
431 os.MkdirAll(filepath.Dir(dir), 0o755)
432 git(root, "clone", "-q", "--bare", src, dir)
433
434 // The same commit already has a build for "unit" from another branch,
435 // still pending. Its filter would exclude this push too, so the only
436 // way to tell the two paths apart is that this one must record nothing.
437 if _, err := st.CreateBuild(repo.ID, "unit", newSHA, "other", `["echo hi"]`, "", "", true); err != nil {
438 t.Fatal(err)
439 }
440
441 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
442
443 statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
444 if err != nil {
445 t.Fatal(err)
446 }
447 if len(statuses) != 0 {
448 t.Fatalf("already-built job recorded a status: %+v", statuses)
449 }
450 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
451 if err != nil || len(builds) != 1 {
452 t.Fatalf("expected only the pre-existing build: %v %v", builds, err)
453 }
454}
455
456// QueueMRBuilds keeps failing open with no diff base at all: deriving a
457// merge base for the MR head is deliberately out of scope here (#172 —
458// filtering a head down to zero jobs leaves it with no statuses, which
459// the require_checks gate reads as unmergeable). This test documents
460// and locks in that choice.
461func TestQueueMRBuildsStillFailsOpen(t *testing.T) {
462 st, repo, uid := newQueueTestRepo(t)
463 git := gitRunner(t)
464 root := t.TempDir()
465
466 src := filepath.Join(root, "src")
467 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
468 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
469 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
470 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
471 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
472 git(root, "init", "-q", "-b", "main", "src")
473 git(src, "add", ".")
474 git(src, "commit", "-q", "-m", "base")
475
476 git(src, "checkout", "-q", "-b", "pr")
477 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
478 git(src, "add", ".")
479 git(src, "commit", "-q", "-m", "docs only")
480 prSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
481
482 dir := RepoDir(root, repo.OwnerName, repo.Name)
483 os.MkdirAll(filepath.Dir(dir), 0o755)
484 git(root, "clone", "-q", "--bare", src, dir)
485 git(dir, "update-ref", "refs/merge-requests/1/head", prSHA)
486
487 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, prSHA)
488
489 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
490 if err != nil || len(builds) != 1 {
491 t.Fatalf("expected the MR head to fail open and queue a build: %v %v", builds, err)
492 }
493}
494
495// A force-push that rewrote the branch must not filter against the old
496// tip. After a rebase, old..new is the difference between two histories
497// — whatever the new base added — so a branch whose own commits touch
498// code looks like a docs-only push and its jobs are skipped. It then
499// reads as green without having run (#176).
500func TestQueueBranchBuildsRebaseFiltersAgainstMergeBase(t *testing.T) {
501 st, repo, uid := newQueueTestRepo(t)
502 git := gitRunner(t)
503 root := t.TempDir()
504
505 src := filepath.Join(root, "src")
506 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
507 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
508 os.MkdirAll(filepath.Join(src, "app"), 0o755)
509 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
510 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
511 os.WriteFile(filepath.Join(src, "app", "a.go"), []byte("package a\n"), 0o644)
512 git(root, "init", "-q", "-b", "main", "src")
513 git(src, "add", ".")
514 git(src, "commit", "-q", "-m", "base")
515
516 // A branch that changes code — the kind of change the filter must
517 // never skip.
518 git(src, "checkout", "-q", "-b", "feat")
519 os.WriteFile(filepath.Join(src, "app", "a.go"), []byte("package a\n\nvar X = 1\n"), 0o644)
520 git(src, "add", ".")
521 git(src, "commit", "-q", "-m", "code change")
522 oldTip := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
523
524 // main moves on with a docs-only commit, and the branch is rebased
525 // onto it — exactly what a fast-forward-only repository forces.
526 git(src, "checkout", "-q", "main")
527 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
528 git(src, "add", ".")
529 git(src, "commit", "-q", "-m", "docs only")
530 git(src, "checkout", "-q", "feat")
531 git(src, "rebase", "-q", "main")
532 newTip := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
533
534 // The trap: between the two tips lies only the docs commit.
535 if diff := git(src, "diff", "--name-only", oldTip, newTip); !strings.Contains(diff, "docs/x.md") ||
536 strings.Contains(diff, "app/a.go") {
537 t.Fatalf("fixture does not reproduce the trap; old..new = %q", diff)
538 }
539
540 dir := RepoDir(root, repo.OwnerName, repo.Name)
541 os.MkdirAll(filepath.Dir(dir), 0o755)
542 git(root, "clone", "-q", "--bare", src, dir)
543
544 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feat", oldTip, newTip, time.Now())
545
546 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
547 if err != nil {
548 t.Fatal(err)
549 }
550 if len(builds) == 0 {
551 t.Fatal("a rebased branch whose commits change code queued no build")
552 }
553}
554
555// A rebase gives a commit a new sha and the same tree. A job that
556// succeeded for that tree has nothing left to prove, so the new commit
557// gets the earlier result as its status instead of a new build (#177).
558func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
559 st, repo, uid := newQueueTestRepo(t)
560 git := gitRunner(t)
561 root := t.TempDir()
562
563 src := filepath.Join(root, "src")
564 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
565 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
566 "jobs:\n unit:\n steps:\n - echo hi\n"), 0o644)
567 git(root, "init", "-q", "-b", "main", "src")
568 git(src, "add", ".")
569 git(src, "commit", "-q", "-m", "base")
570 first := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
571 // Same tree, new sha: what a rebase onto an unrelated base produces.
572 git(src, "commit", "-q", "--allow-empty", "-m", "rewritten")
573 second := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
574 if git(src, "rev-parse", first+"^{tree}") != git(src, "rev-parse", second+"^{tree}") {
575 t.Fatal("fixture: trees differ")
576 }
577
578 dir := RepoDir(root, repo.OwnerName, repo.Name)
579 os.MkdirAll(filepath.Dir(dir), 0o755)
580 git(root, "clone", "-q", "--bare", src, dir)
581
582 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now())
583 builds, _ := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
584 if len(builds) != 1 {
585 t.Fatalf("first commit queued %d builds, want 1", len(builds))
586 }
587 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
588 t.Fatalf("claim: ok=%v err=%v", ok, err)
589 }
590 if err := st.FinishBuild(builds[0].ID, "success"); err != nil {
591 t.Fatal(err)
592 }
593
594 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now())
595 builds, _ = st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
596 if len(builds) != 1 {
597 t.Fatalf("same tree queued a second build: %+v", builds)
598 }
599 statuses, _ := st.ListCommitStatuses(repo.ID, second)
600 found := false
601 for _, s := range statuses {
602 if s.Context == "ci/unit" && s.State == "success" && strings.Contains(s.Description, "same tree") {
603 found = true
604 }
605 }
606 if !found {
607 t.Fatalf("second commit has no success status from the first: %+v", statuses)
608 }
609}
610
611// build list's --ref, --status and --job flags narrow the CLI listing the
612// same way the store filter does, and combine when more than one is given.
613// An invalid --status is refused rather than silently matching nothing (#224).
614func TestBuildListFlagsFilter(t *testing.T) {
615 st, repo, uid := newQueueTestRepo(t)
616 if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil {
617 t.Fatal(err)
618 }
619 if _, err := st.CreateBuild(repo.ID, "lint", "bbb", "feature", `["true"]`, "", "", true); err != nil {
620 t.Fatal(err)
621 }
622 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
623 t.Fatalf("claim: %v ok=%v", err, ok)
624 }
625 if err := st.FinishBuild(1, "failure"); err != nil {
626 t.Fatal(err)
627 }
628 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
629
630 run := func(args ...string) string {
631 t.Helper()
632 out := c.Stdout.(*bytes.Buffer)
633 out.Reset()
634 errOut.Reset()
635 argv := append([]string{"build", "list", repo.Path()}, args...)
636 if code := Dispatch(c, argv); code != protocol.ExitOK {
637 t.Fatalf("build list %v: exit %d: %s", args, code, errOut.String())
638 }
639 return out.String()
640 }
641
642 if out := run(); !strings.Contains(out, "unit") || !strings.Contains(out, "lint") {
643 t.Fatalf("unfiltered listing missing a build:\n%s", out)
644 }
645 if out := run("--ref", "main"); !strings.Contains(out, "unit") || strings.Contains(out, "lint") {
646 t.Fatalf("--ref main:\n%s", out)
647 }
648 if out := run("--job", "lint"); strings.Contains(out, "unit") || !strings.Contains(out, "lint") {
649 t.Fatalf("--job lint:\n%s", out)
650 }
651 if out := run("--status", "failure"); !strings.Contains(out, "unit") || strings.Contains(out, "lint") {
652 t.Fatalf("--status failure:\n%s", out)
653 }
654 if out := run("--ref", "main", "--status", "pending"); strings.TrimSpace(out) != "" {
655 t.Fatalf("non-matching combination returned rows:\n%s", out)
656 }
657
658 out := c.Stdout.(*bytes.Buffer)
659 out.Reset()
660 errOut.Reset()
661 if code := Dispatch(c, []string{"build", "list", repo.Path(), "--status", "bogus"}); code != protocol.ExitUsage {
662 t.Fatalf("bad --status: exit %d, want %d (usage)", code, protocol.ExitUsage)
663 }
664 if !strings.Contains(errOut.String(), "pending") || !strings.Contains(errOut.String(), "cancelled") {
665 t.Fatalf("bad --status error does not name the valid states: %s", errOut.String())
666 }
667}