| @@ -0,0 +1,464 @@ |
| |
1 | package hookd |
| |
2 | |
| |
3 | import ( |
| |
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 | |
| |
35 | const 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 | |
| |
59 | var shapeJobs = []string{"plain", "onapp", "notapp", "nightly", "release"} |
| |
60 | |
| |
61 | const zeroSHA40 = "0000000000000000000000000000000000000000" |
| |
62 | |
| |
63 | type 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 | |
| |
82 | func 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 | |
| |
129 | func (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 | |
| |
144 | func (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 | |
| |
150 | func (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. |
| |
156 | func (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. |
| |
161 | func (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 | |
| |
168 | func (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. |
| |
175 | func (f *shapeFixture) finish(status string) { |
| |
176 | for { |
| |
177 | b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}) |
| |
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 | |
| |
191 | func (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 | |
| |
203 | func (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. |
| |
216 | func (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. |
| |
227 | func (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 | |
| |
267 | type pushShape struct { |
| |
268 | name string |
| |
269 | run func(f *shapeFixture) |
| |
270 | want []string // plain, onapp, notapp, nightly, release, ci/config |
| |
271 | } |
| |
272 | |
| |
273 | var 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); 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 | |
| |
403 | var shapeColumns = append([]string{"push"}, append(append([]string{}, shapeJobs...), "ci/config")...) |
| |
404 | |
| |
405 | // tableRow renders one shape the way the wiki's table carries it. |
| |
406 | func tableRow(cells []string) string { |
| |
407 | return "| " + strings.Join(cells, " | ") + " |" |
| |
408 | } |
| |
409 | |
| |
410 | // normalizeRow strips the alignment padding org-mode adds to a table. |
| |
411 | func 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 | |
| |
419 | func 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. |
| |
437 | func 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 | } |