internal/control/build_test.go

v1.43.1
gitbay/internal/control/build_test.go history · blame · raw

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