internal/control/checksgate_test.go
168 lines · 5859 bytes
1package control
2
3import (
4 "os"
5 "path/filepath"
6 "slices"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/store"
11)
12
13// gatesForHead builds a repository with require_checks on, a bare dir
14// holding the given .gitbay/ci.yml (empty string for none), and one MR
15// whose head carries no statuses at all. seed records a status on the
16// base commit, standing for a repository that reports from outside.
17func gatesForHead(t *testing.T, ciYML string) GatesOut {
18 return gatesForHeadSeeded(t, ciYML, false)
19}
20
21func gatesForHeadSeeded(t *testing.T, ciYML string, seed bool) GatesOut {
22 return gatesFor(t, ciYML, nil, func(st *store.Store, repoID, uid int64, targetSHA, _ string) {
23 if !seed {
24 return
25 }
26 if err := st.SetCommitStatus(repoID, targetSHA, "lint", "success", "", "", uid); err != nil {
27 t.Fatal(err)
28 }
29 })
30}
31
32// gatesFor builds a repository with require_checks on and set applied to
33// its settings, a bare dir holding the given .gitbay/ci.yml (empty
34// string for none), and one MR; seed records statuses before the gates
35// are computed.
36func gatesFor(t *testing.T, ciYML string, set func(*store.RepoSettings),
37 seed func(st *store.Store, repoID, uid int64, targetSHA, headSHA string)) GatesOut {
38 t.Helper()
39 st, repo, uid := newQueueTestRepo(t)
40 if _, err := st.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
41 s.RequireChecks = true
42 if set != nil {
43 set(s)
44 }
45 }); err != nil {
46 t.Fatal(err)
47 }
48 repo, err := st.RepoByID(repo.ID)
49 if err != nil {
50 t.Fatal(err)
51 }
52
53 git := gitRunner(t)
54 root := t.TempDir()
55 src := filepath.Join(root, "src")
56 os.MkdirAll(src, 0o755)
57 git(root, "init", "-q", "-b", "main", "src")
58 os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644)
59 git(src, "add", ".")
60 git(src, "commit", "-q", "-m", "base")
61 targetSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
62 git(src, "checkout", "-q", "-b", "feature")
63 if ciYML != "" {
64 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
65 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(ciYML), 0o644)
66 }
67 os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644)
68 git(src, "add", ".")
69 git(src, "commit", "-q", "-m", "change")
70 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
71
72 dir := RepoDir(root, repo.OwnerName, repo.Name)
73 os.MkdirAll(filepath.Dir(dir), 0o755)
74 git(root, "clone", "-q", "--bare", src, dir)
75
76 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil {
77 t.Fatal(err)
78 }
79 mr, err := st.MRByNumber(repo.ID, 1)
80 if err != nil {
81 t.Fatal(err)
82 }
83 if seed != nil {
84 seed(st, repo.ID, uid, targetSHA, headSHA)
85 }
86 g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA)
87 if err != nil {
88 t.Fatal(err)
89 }
90 return g
91}
92
93func checksUnmet(g GatesOut) bool {
94 for _, u := range g.Unmet {
95 if strings.Contains(u, "green checks") {
96 return true
97 }
98 }
99 return false
100}
101
102// A repository with require_checks on and no CI configuration at its head
103// can never report a status, so the gate has nothing to wait for and the
104// merge must go through rather than be refused forever.
105func TestRequireChecksPassesWithNoCIConfig(t *testing.T) {
106 if g := gatesForHead(t, ""); checksUnmet(g) {
107 t.Fatalf("refused a head with no CI configuration: %v", g.Unmet)
108 }
109}
110
111// Jobs that only run on a schedule or on tags never report on a merge
112// request head either.
113func TestRequireChecksPassesWithNoPushJobs(t *testing.T) {
114 cfg := "jobs:\n nightly:\n schedule: \"0 3 * * *\"\n steps:\n - echo hi\n" +
115 " release:\n tags: \"v*\"\n steps:\n - echo hi\n"
116 if g := gatesForHead(t, cfg); checksUnmet(g) {
117 t.Fatalf("refused a head whose jobs never run on a push: %v", g.Unmet)
118 }
119}
120
121// A push job at the head should have reported something. Silence there
122// means CI did not run, which is what the gate is for.
123func TestRequireChecksRefusesSilentPushJob(t *testing.T) {
124 cfg := "jobs:\n unit:\n steps:\n - echo hi\n"
125 if g := gatesForHead(t, cfg); !checksUnmet(g) {
126 t.Fatalf("allowed a head whose push job reported nothing: %v", g.Unmet)
127 }
128}
129
130// A repository whose checks come from outside — `status set`, no
131// .gitbay/ci.yml — looks like one with no CI at all. Having reported
132// before is what says a report was coming, so a silent head there is
133// still refused.
134func TestRequireChecksRefusesSilentHeadInReportingRepo(t *testing.T) {
135 if g := gatesForHeadSeeded(t, "", true); !checksUnmet(g) {
136 t.Fatalf("allowed a silent head in a repository that reports statuses: %v", g.Unmet)
137 }
138}
139
140// A required context that has not reported holds the merge as pending,
141// even when every status that did report is green (#258).
142func TestRequiredContextMissingIsPending(t *testing.T) {
143 g := gatesFor(t, "", func(s *store.RepoSettings) { s.RequiredContexts = []string{"ext/deploy", "lint"} },
144 func(st *store.Store, repoID, uid int64, _, headSHA string) {
145 if err := st.SetCommitStatus(repoID, headSHA, "lint", "success", "", "", uid); err != nil {
146 t.Fatal(err)
147 }
148 })
149 if g.Checks != "pending" || !slices.Equal(g.ChecksMissing, []string{"ext/deploy"}) {
150 t.Fatalf("checks %q, missing %v", g.Checks, g.ChecksMissing)
151 }
152 if !checksUnmet(g) || !strings.Contains(strings.Join(g.Unmet, "\n"), "ext/deploy=missing") {
153 t.Fatalf("unmet: %v", g.Unmet)
154 }
155}
156
157// Every required context reported green: nothing is held.
158func TestRequiredContextsReportedPass(t *testing.T) {
159 g := gatesFor(t, "", func(s *store.RepoSettings) { s.RequiredContexts = []string{"lint"} },
160 func(st *store.Store, repoID, uid int64, _, headSHA string) {
161 if err := st.SetCommitStatus(repoID, headSHA, "lint", "success", "", "", uid); err != nil {
162 t.Fatal(err)
163 }
164 })
165 if checksUnmet(g) || len(g.ChecksMissing) != 0 || g.Checks != "success" {
166 t.Fatalf("checks %q, missing %v, unmet %v", g.Checks, g.ChecksMissing, g.Unmet)
167 }
168}