internal/hookd/pushshapes_test.go
464 lines · 13977 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 {"claimed builds whose runner vanished", func(f *shapeFixture) {
367 f.git(f.src, "checkout", "-q", "-b", "feat")
368 c1 := f.appCommit("more")
369 f.push("feat", zeroSHA40, c1)
370 for i := 0; i < 2; i++ {
371 if _, ok, err := f.st.ClaimBuild(nil, true); err != nil || !ok {
372 f.t.Fatalf("claim: %v ok=%v", err, ok)
373 }
374 }
375 if _, err := f.st.DB.Exec(`UPDATE builds SET started_at = '2020-01-01T00:00:00Z'`); err != nil {
376 f.t.Fatal(err)
377 }
378 f.mark(c1)
379 f.sched.RunDue(time.Now())
380 }, []string{"abandoned", "abandoned", "—", "—", "—", "—"}},
381
382 {"push to the default branch with an old sha that cannot be diffed", func(f *shapeFixture) {
383 c := f.appCommit("more")
384 f.mark(c)
385 f.push("main", strings.Repeat("1", 40), c)
386 }, []string{"queued", "queued", "queued", "registered", "—", "—"}},
387
388 {"push with a broken ci.yml", func(f *shapeFixture) {
389 f.write(".gitbay/ci.yml", "jobs: [\n")
390 c := f.appCommit("broken")
391 f.mark(c)
392 f.push("main", f.base, c)
393 }, []string{"—", "—", "—", "—", "—", "failure"}},
394
395 {"push with no ci.yml", func(f *shapeFixture) {
396 f.git(f.src, "rm", "-q", ".gitbay/ci.yml")
397 c := f.appCommit("no ci")
398 f.mark(c)
399 f.push("main", f.base, c)
400 }, []string{"—", "—", "—", "—", "—", "—"}},
401}
402
403var shapeColumns = append([]string{"push"}, append(append([]string{}, shapeJobs...), "ci/config")...)
404
405// tableRow renders one shape the way the wiki's table carries it.
406func tableRow(cells []string) string {
407 return "| " + strings.Join(cells, " | ") + " |"
408}
409
410// normalizeRow strips the alignment padding org-mode adds to a table.
411func normalizeRow(line string) string {
412 cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
413 for i := range cells {
414 cells[i] = strings.TrimSpace(cells[i])
415 }
416 return tableRow(cells)
417}
418
419func TestPushShapes(t *testing.T) {
420 for _, sh := range pushShapes {
421 t.Run(sh.name, func(t *testing.T) {
422 f := newShapeFixture(t)
423 sh.run(f)
424 got := f.observe()
425 for i, j := range append(append([]string{}, shapeJobs...), "ci/config") {
426 if got[j] != sh.want[i] {
427 t.Errorf("%s: %s = %q, want %q", sh.name, j, got[j], sh.want[i])
428 }
429 }
430 })
431 }
432}
433
434// The wiki's table is the test's expectations rendered as rows, and the
435// header names the columns; a row the page lacks or states differently
436// fails here.
437func TestPushShapesTableOnWiki(t *testing.T) {
438 raw, err := os.ReadFile(filepath.Join("..", "..", ".gitbay", "wiki", "CI.org"))
439 if err != nil {
440 t.Fatal(err)
441 }
442 have := map[string]bool{}
443 for _, line := range strings.Split(string(raw), "\n") {
444 if strings.HasPrefix(strings.TrimSpace(line), "|") {
445 have[normalizeRow(line)] = true
446 }
447 }
448 if !have[tableRow(shapeColumns)] {
449 t.Errorf("CI.org lacks the header row %s", tableRow(shapeColumns))
450 }
451 for _, sh := range pushShapes {
452 row := tableRow(append([]string{sh.name}, sh.want...))
453 if !have[row] {
454 t.Errorf("CI.org lacks the row %s", row)
455 }
456 }
457 if t.Failed() {
458 fmt.Fprintln(os.Stderr, "the table as the code has it:")
459 fmt.Fprintln(os.Stderr, tableRow(shapeColumns))
460 for _, sh := range pushShapes {
461 fmt.Fprintln(os.Stderr, tableRow(append([]string{sh.name}, sh.want...)))
462 }
463 }
464}