internal/hookd/pushshapes_test.go

e91fdfe0cc2f9bfdcf99bf1a316d219bed8e2ebf
gitbay/internal/hookd/pushshapes_test.go history · blame · raw

471 lines · 14271 bytes

  1package hookd
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10	"time"
 11
 12	"gitbay.org/gitbay/internal/ci"
 13	"gitbay.org/gitbay/internal/config"
 14	"gitbay.org/gitbay/internal/control"
 15	"gitbay.org/gitbay/internal/store"
 16)
 17
 18// Every push shape, and what dedupe, the path filters, the schedules and
 19// the reaper make of it. The table on the wiki's CI page is these rows;
 20// the test asserts each against the code and then against the page, so
 21// the two cannot drift apart (#184). #176 was a shape nobody had listed.
 22//
 23// One ci.yml for every shape, five jobs that each isolate one rule:
 24//
 25//	plain    no filter
 26//	onapp    paths: app/**
 27//	notapp   paths-ignore: app/**
 28//	nightly  schedule
 29//	release  tags: v*
 30//
 31// Every push in the table changes app/x and nothing else, so a filtered
 32// push queues onapp and skips notapp, and a push whose filters cannot be
 33// evaluated queues both.
 34
 35const shapesCI = `jobs:
 36  plain:
 37    steps:
 38      - echo plain
 39  onapp:
 40    paths:
 41      - app/**
 42    steps:
 43      - echo onapp
 44  notapp:
 45    paths-ignore:
 46      - app/**
 47    steps:
 48      - echo notapp
 49  nightly:
 50    schedule: "0 3 * * *"
 51    steps:
 52      - echo nightly
 53  release:
 54    tags: "v*"
 55    steps:
 56      - echo release
 57`
 58
 59var shapeJobs = []string{"plain", "onapp", "notapp", "nightly", "release"}
 60
 61const zeroSHA40 = "0000000000000000000000000000000000000000"
 62
 63type shapeFixture struct {
 64	t     *testing.T
 65	st    *store.Store
 66	repo  store.Repo
 67	uid   int64
 68	root  string
 69	src   string
 70	dir   string
 71	srv   *Server
 72	sched *ci.Scheduler
 73	base  string
 74	// Snapshot taken by mark, so the observation reports only what the
 75	// shape itself produced.
 76	markedSHA    string
 77	buildsBefore int
 78	statusBefore map[string]string
 79	schedBefore  map[string]bool
 80}
 81
 82func newShapeFixture(t *testing.T) *shapeFixture {
 83	t.Helper()
 84	st, err := store.Open(":memory:")
 85	if err != nil {
 86		t.Fatal(err)
 87	}
 88	t.Cleanup(func() { st.Close() })
 89	if err := st.MigrateUp(); err != nil {
 90		t.Fatal(err)
 91	}
 92	uid, err := st.CreateUser("alice", false)
 93	if err != nil {
 94		t.Fatal(err)
 95	}
 96	repoID, err := st.CreateRepo("user", uid, "app", "public")
 97	if err != nil {
 98		t.Fatal(err)
 99	}
100	repo, err := st.RepoByID(repoID)
101	if err != nil {
102		t.Fatal(err)
103	}
104	root := t.TempDir()
105	f := &shapeFixture{t: t, st: st, repo: repo, uid: uid, root: root, src: filepath.Join(root, "src")}
106	f.dir = control.RepoDir(root, repo.OwnerName, repo.Name)
107	cfg := config.Config{}
108	cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test"
109	f.srv = &Server{cfg: cfg, st: st}
110	f.sched = &ci.Scheduler{St: st, SiteURL: "https://x.test",
111		RepoDir: func(owner, name string) string { return control.RepoDir(root, owner, name) }}
112
113	os.MkdirAll(filepath.Join(f.src, ".gitbay"), 0o755)
114	os.MkdirAll(filepath.Join(f.src, "app"), 0o755)
115	os.MkdirAll(filepath.Join(f.src, "docs"), 0o755)
116	f.write(".gitbay/ci.yml", shapesCI)
117	f.write("app/x", "1\n")
118	f.write("docs/d", "d\n")
119	f.git(root, "init", "-q", "-b", "main", "src")
120	f.git(f.src, "add", ".")
121	f.git(f.src, "commit", "-q", "-m", "base")
122	f.base = f.sha("HEAD")
123	os.MkdirAll(filepath.Dir(f.dir), 0o755)
124	f.git(root, "init", "-q", "--bare", f.dir)
125	f.sync()
126	return f
127}
128
129func (f *shapeFixture) git(dir string, args ...string) string {
130	f.t.Helper()
131	cmd := exec.Command("git", args...)
132	cmd.Dir = dir
133	cmd.Env = append(os.Environ(),
134		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
135		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
136		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
137	out, err := cmd.CombinedOutput()
138	if err != nil {
139		f.t.Fatalf("git %v: %v\n%s", args, err, out)
140	}
141	return string(out)
142}
143
144func (f *shapeFixture) write(rel, content string) {
145	if err := os.WriteFile(filepath.Join(f.src, rel), []byte(content), 0o644); err != nil {
146		f.t.Fatal(err)
147	}
148}
149
150func (f *shapeFixture) sha(ref string) string {
151	return strings.TrimSpace(f.git(f.src, "rev-parse", ref))
152}
153
154// sync moves every branch and tag of the working repository into the
155// served bare one, as the pushes the shapes stand for would have.
156func (f *shapeFixture) sync() {
157	f.git(f.src, "push", "-q", "--force", f.dir, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
158}
159
160// appCommit commits a change to app/x on the current branch.
161func (f *shapeFixture) appCommit(msg string) string {
162	f.write("app/x", msg+"\n")
163	f.git(f.src, "add", ".")
164	f.git(f.src, "commit", "-q", "-m", msg)
165	return f.sha("HEAD")
166}
167
168func (f *shapeFixture) push(branch, old, sha string) {
169	f.sync()
170	control.QueueBranchBuilds(f.st, f.root, "https://x.test", f.repo, f.uid, branch, old, sha, time.Now())
171}
172
173// finish reports every pending build as a runner would, status on the
174// commit included.
175func (f *shapeFixture) finish(status string) {
176	for {
177		b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}, true)
178		if err != nil {
179			f.t.Fatal(err)
180		}
181		if !ok {
182			return
183		}
184		if err := f.st.FinishBuild(b.ID, status); err != nil {
185			f.t.Fatal(err)
186		}
187		f.st.SetCommitStatus(f.repo.ID, b.SHA, "ci/"+b.Job, status, "reported", "", f.uid)
188	}
189}
190
191func (f *shapeFixture) statuses(sha string) map[string]string {
192	out := map[string]string{}
193	list, err := f.st.ListCommitStatuses(f.repo.ID, sha)
194	if err != nil {
195		f.t.Fatal(err)
196	}
197	for _, s := range list {
198		out[s.Context] = s.State + ": " + s.Description
199	}
200	return out
201}
202
203func (f *shapeFixture) schedules() map[string]bool {
204	out := map[string]bool{}
205	due, err := f.st.DueSchedules("9999-01-01T00:00:00Z")
206	if err != nil {
207		f.t.Fatal(err)
208	}
209	for _, s := range due {
210		out[s.Job] = true
211	}
212	return out
213}
214
215// mark snapshots the state the observed action starts from.
216func (f *shapeFixture) mark(sha string) {
217	builds, err := f.st.ListBuilds(f.repo.ID, 1000)
218	if err != nil {
219		f.t.Fatal(err)
220	}
221	f.markedSHA, f.buildsBefore = sha, len(builds)
222	f.statusBefore, f.schedBefore = f.statuses(sha), f.schedules()
223}
224
225// observe reduces what the action produced to one word per job, plus
226// the ci/config status. The words are the table's vocabulary.
227func (f *shapeFixture) observe() map[string]string {
228	out := map[string]string{}
229	builds, err := f.st.ListBuilds(f.repo.ID, 1000)
230	if err != nil {
231		f.t.Fatal(err)
232	}
233	fresh := map[string]store.Build{}
234	for _, b := range builds[:len(builds)-f.buildsBefore] { // newest first
235		fresh[b.Job] = b
236	}
237	after, sched := f.statuses(f.markedSHA), f.schedules()
238	for _, j := range shapeJobs {
239		switch st, changed := after["ci/"+j], after["ci/"+j] != f.statusBefore["ci/"+j]; {
240		case fresh[j].Status == "pending" && !fresh[j].Trusted:
241			out[j] = "queued, untrusted"
242		case fresh[j].Status == "pending":
243			out[j] = "queued"
244		case changed && strings.HasPrefix(st, "failure: build abandoned"):
245			out[j] = "abandoned"
246		case changed && strings.HasPrefix(st, "skipped:"):
247			out[j] = "skipped"
248		case changed && strings.HasPrefix(st, "success:") && strings.Contains(st, "same tree"):
249			out[j] = "reused"
250		case changed:
251			out[j] = st
252		case sched[j] && !f.schedBefore[j]:
253			out[j] = "registered"
254		default:
255			out[j] = "—"
256		}
257	}
258	cfg, changed := after["ci/config"], after["ci/config"] != f.statusBefore["ci/config"]
259	if changed && strings.HasPrefix(cfg, "failure:") {
260		out["ci/config"] = "failure"
261	} else {
262		out["ci/config"] = "—"
263	}
264	return out
265}
266
267type pushShape struct {
268	name string
269	run  func(f *shapeFixture)
270	want []string // plain, onapp, notapp, nightly, release, ci/config
271}
272
273var pushShapes = []pushShape{
274	{"first push of the default branch", func(f *shapeFixture) {
275		f.mark(f.base)
276		f.push("main", zeroSHA40, f.base)
277	}, []string{"queued", "queued", "queued", "registered", "—", "—"}},
278
279	{"push to the default branch", func(f *shapeFixture) {
280		c := f.appCommit("more")
281		f.mark(c)
282		f.push("main", f.base, c)
283	}, []string{"queued", "queued", "skipped", "registered", "—", "—"}},
284
285	{"push to another branch", func(f *shapeFixture) {
286		f.git(f.src, "checkout", "-q", "-b", "feat")
287		c := f.appCommit("more")
288		f.mark(c)
289		f.push("feat", f.base, c)
290	}, []string{"queued", "queued", "skipped", "—", "—", "—"}},
291
292	{"a new branch, no old sha", func(f *shapeFixture) {
293		f.git(f.src, "checkout", "-q", "-b", "feat")
294		c := f.appCommit("more")
295		f.mark(c)
296		f.push("feat", zeroSHA40, c)
297	}, []string{"queued", "queued", "skipped", "—", "—", "—"}},
298
299	{"rebase onto a moved default branch, old not an ancestor", func(f *shapeFixture) {
300		f.git(f.src, "checkout", "-q", "-b", "feat")
301		c1 := f.appCommit("more")
302		f.git(f.src, "checkout", "-q", "main")
303		f.write("docs/d", "moved\n")
304		f.git(f.src, "add", ".")
305		f.git(f.src, "commit", "-q", "-m", "docs on main")
306		f.git(f.src, "checkout", "-q", "feat")
307		f.git(f.src, "rebase", "-q", "main")
308		c2 := f.sha("HEAD")
309		f.mark(c2)
310		f.push("feat", c1, c2)
311	}, []string{"queued", "queued", "skipped", "—", "—", "—"}},
312
313	{"rewritten commit, same tree as a passed build", func(f *shapeFixture) {
314		f.git(f.src, "checkout", "-q", "-b", "feat")
315		c1 := f.appCommit("more")
316		f.push("feat", zeroSHA40, c1)
317		f.finish("success")
318		f.git(f.src, "commit", "-q", "--allow-empty", "-m", "rewritten")
319		c2 := f.sha("HEAD")
320		f.mark(c2)
321		f.push("feat", c1, c2)
322	}, []string{"reused", "reused", "skipped", "—", "—", "—"}},
323
324	{"fast-forward of a commit built on another branch", func(f *shapeFixture) {
325		f.git(f.src, "checkout", "-q", "-b", "feat")
326		c1 := f.appCommit("more")
327		f.push("feat", zeroSHA40, c1)
328		f.finish("success")
329		f.git(f.src, "checkout", "-q", "main")
330		f.git(f.src, "merge", "-q", "--ff-only", "feat")
331		f.mark(c1)
332		f.push("main", f.base, c1)
333	}, []string{"—", "—", "—", "registered", "—", "—"}},
334
335	{"a commit whose earlier build failed, on a new branch", func(f *shapeFixture) {
336		f.git(f.src, "checkout", "-q", "-b", "feat")
337		c1 := f.appCommit("more")
338		f.push("feat", zeroSHA40, c1)
339		f.finish("failure")
340		f.git(f.src, "branch", "-q", "again", "feat")
341		f.mark(c1)
342		f.push("again", zeroSHA40, c1)
343	}, []string{"queued", "queued", "—", "—", "—", "—"}},
344
345	{"merge request head from a fork", func(f *shapeFixture) {
346		f.git(f.src, "checkout", "-q", "-b", "feat")
347		c1 := f.appCommit("more")
348		f.git(f.src, "push", "-q", f.dir, "+refs/heads/feat:refs/merge-requests/1/head")
349		f.mark(c1)
350		control.QueueMRBuilds(f.st, f.root, "https://x.test", f.repo, f.uid, 1, c1)
351	}, []string{"queued, untrusted", "queued, untrusted", "queued, untrusted", "—", "—", "—"}},
352
353	{"tag push", func(f *shapeFixture) {
354		f.git(f.src, "tag", "v1")
355		f.sync()
356		f.mark(f.base)
357		f.srv.queueTagBuilds(f.repo, f.uid, "v1", f.base)
358	}, []string{"—", "—", "—", "—", "queued", "—"}},
359
360	{"schedule tick on the default branch", func(f *shapeFixture) {
361		f.push("main", zeroSHA40, f.base)
362		f.mark(f.base)
363		f.sched.RunDue(time.Now().AddDate(1, 0, 0))
364	}, []string{"—", "—", "—", "queued", "—", "—"}},
365
366	{"schedule tick while the last scheduled build is still pending", func(f *shapeFixture) {
367		f.push("main", zeroSHA40, f.base)
368		f.sched.RunDue(time.Now().AddDate(1, 0, 0))
369		f.mark(f.base)
370		f.sched.RunDue(time.Now().AddDate(2, 0, 0))
371	}, []string{"—", "—", "—", "—", "—", "—"}},
372
373	{"claimed builds whose runner vanished", func(f *shapeFixture) {
374		f.git(f.src, "checkout", "-q", "-b", "feat")
375		c1 := f.appCommit("more")
376		f.push("feat", zeroSHA40, c1)
377		for i := 0; i < 2; i++ {
378			if _, ok, err := f.st.ClaimBuild(nil, true); err != nil || !ok {
379				f.t.Fatalf("claim: %v ok=%v", err, ok)
380			}
381		}
382		if _, err := f.st.DB.Exec(`UPDATE builds SET started_at = '2020-01-01T00:00:00Z'`); err != nil {
383			f.t.Fatal(err)
384		}
385		f.mark(c1)
386		f.sched.RunDue(time.Now())
387	}, []string{"abandoned", "abandoned", "—", "—", "—", "—"}},
388
389	{"push to the default branch with an old sha that cannot be diffed", func(f *shapeFixture) {
390		c := f.appCommit("more")
391		f.mark(c)
392		f.push("main", strings.Repeat("1", 40), c)
393	}, []string{"queued", "queued", "queued", "registered", "—", "—"}},
394
395	{"push with a broken ci.yml", func(f *shapeFixture) {
396		f.write(".gitbay/ci.yml", "jobs: [\n")
397		c := f.appCommit("broken")
398		f.mark(c)
399		f.push("main", f.base, c)
400	}, []string{"—", "—", "—", "—", "—", "failure"}},
401
402	{"push with no ci.yml", func(f *shapeFixture) {
403		f.git(f.src, "rm", "-q", ".gitbay/ci.yml")
404		c := f.appCommit("no ci")
405		f.mark(c)
406		f.push("main", f.base, c)
407	}, []string{"—", "—", "—", "—", "—", "—"}},
408}
409
410var shapeColumns = append([]string{"push"}, append(append([]string{}, shapeJobs...), "ci/config")...)
411
412// tableRow renders one shape the way the wiki's table carries it.
413func tableRow(cells []string) string {
414	return "| " + strings.Join(cells, " | ") + " |"
415}
416
417// normalizeRow strips the alignment padding org-mode adds to a table.
418func normalizeRow(line string) string {
419	cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
420	for i := range cells {
421		cells[i] = strings.TrimSpace(cells[i])
422	}
423	return tableRow(cells)
424}
425
426func TestPushShapes(t *testing.T) {
427	for _, sh := range pushShapes {
428		t.Run(sh.name, func(t *testing.T) {
429			f := newShapeFixture(t)
430			sh.run(f)
431			got := f.observe()
432			for i, j := range append(append([]string{}, shapeJobs...), "ci/config") {
433				if got[j] != sh.want[i] {
434					t.Errorf("%s: %s = %q, want %q", sh.name, j, got[j], sh.want[i])
435				}
436			}
437		})
438	}
439}
440
441// The wiki's table is the test's expectations rendered as rows, and the
442// header names the columns; a row the page lacks or states differently
443// fails here.
444func TestPushShapesTableOnWiki(t *testing.T) {
445	raw, err := os.ReadFile(filepath.Join("..", "..", ".gitbay", "wiki", "CI.org"))
446	if err != nil {
447		t.Fatal(err)
448	}
449	have := map[string]bool{}
450	for _, line := range strings.Split(string(raw), "\n") {
451		if strings.HasPrefix(strings.TrimSpace(line), "|") {
452			have[normalizeRow(line)] = true
453		}
454	}
455	if !have[tableRow(shapeColumns)] {
456		t.Errorf("CI.org lacks the header row %s", tableRow(shapeColumns))
457	}
458	for _, sh := range pushShapes {
459		row := tableRow(append([]string{sh.name}, sh.want...))
460		if !have[row] {
461			t.Errorf("CI.org lacks the row %s", row)
462		}
463	}
464	if t.Failed() {
465		fmt.Fprintln(os.Stderr, "the table as the code has it:")
466		fmt.Fprintln(os.Stderr, tableRow(shapeColumns))
467		for _, sh := range pushShapes {
468			fmt.Fprintln(os.Stderr, tableRow(append([]string{sh.name}, sh.want...)))
469		}
470	}
471}