internal/control/build_test.go

419f6dfdc5489a0c6374e36dd1ebbfca68040056
gitbay/internal/control/build_test.go history · blame · raw

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}