| @@ -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 | } |