require_checks waits only on CI that would report !391

merged merged by cmc on 2026-09-13 06:57 UTC · krz/gitbay:fix/checks-gate-no-jobs into main

5 files changed, +228 −5

Layout: unified · split

.gitbay/wiki/API.org +6 −2
@@ -109,8 +109,12 @@ One row per (commit, context): re-reporting updates in place. States:
109worst of them. Statuses appear on commit pages, MR pages, and 109worst of them. Statuses appear on commit pages, MR pages, and
110=mr show=, each with =updated_at=; a =ci/<job>= status also carries 110=mr show=, each with =updated_at=; a =ci/<job>= status also carries
111=duration=, read from the build behind it, once that build has 111=duration=, read from the build behind it, once that build has
112finished. With =repo settings require-checks <repo> on=, merging 112finished. With =repo settings require-checks <repo> on=, every status
113requires the MR head to carry statuses and all of them green. Each 113on the MR head must be green, and a head something was going to report
114on must carry some: a =.gitbay/ci.yml= with a job a push runs, or a
115repository that has recorded a status before, which is what reporting
116from outside through =status set= looks like. A repository where
117nothing has ever reported merges. Each
114report also emits a =status= event to webhooks. 118report also emits a =status= event to webhooks.
115 119
116* Webhooks 120* Webhooks
e2e/ci_nojobs_test.go added +50
@@ -0,0 +1,50 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7)
8
9// require_checks on a repository with no CI configuration used to refuse
10// every merge with "none were reported", and the only way out was turning
11// the setting off. Nothing was ever going to report, so the gate has
12// nothing to wait for.
13func TestRequireChecksWithoutCIConfigMerges(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
17 "--email", "alice@example.test", "--verified")
18
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-checks", "alice/app", "on"); code != 0 {
23 t.Fatalf("require-checks on: %s", errOut)
24 }
25
26 work := t.TempDir()
27 env := inst.gitEnv(aliceKey)
28 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
29 dir := filepath.Join(work, "w")
30 os.WriteFile(filepath.Join(dir, "README"), []byte("x\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "base")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35
36 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
37 os.WriteFile(filepath.Join(dir, "README"), []byte("y\n"), 0o644)
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "change")
40 mustGit(t, dir, env, "push", "-q", "origin", "feature")
41
42 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
43 "--source", "feature", "--target", "main", "--title", "change"); code != 0 {
44 t.Fatalf("mr create: %s", errOut)
45 }
46 if out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1",
47 "--strategy", "merge"); code != 0 {
48 t.Fatalf("mr merge refused under require_checks with no CI: %s %s", out, errOut)
49 }
50}
internal/control/checksgate_test.go added +120
@@ -0,0 +1,120 @@
1package control
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// gatesForHead builds a repository with require_checks on, a bare dir
13// holding the given .gitbay/ci.yml (empty string for none), and one MR
14// whose head carries no statuses at all. seed records a status on the
15// base commit, standing for a repository that reports from outside.
16func gatesForHead(t *testing.T, ciYML string) GatesOut {
17 return gatesForHeadSeeded(t, ciYML, false)
18}
19
20func gatesForHeadSeeded(t *testing.T, ciYML string, seed bool) GatesOut {
21 t.Helper()
22 st, repo, uid := newQueueTestRepo(t)
23 if _, err := st.UpdateRepoSettings(repo.ID, func(set *store.RepoSettings) { set.RequireChecks = true }); err != nil {
24 t.Fatal(err)
25 }
26 repo, err := st.RepoByID(repo.ID)
27 if err != nil {
28 t.Fatal(err)
29 }
30
31 git := gitRunner(t)
32 root := t.TempDir()
33 src := filepath.Join(root, "src")
34 os.MkdirAll(src, 0o755)
35 git(root, "init", "-q", "-b", "main", "src")
36 os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644)
37 git(src, "add", ".")
38 git(src, "commit", "-q", "-m", "base")
39 targetSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
40 git(src, "checkout", "-q", "-b", "feature")
41 if ciYML != "" {
42 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
43 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(ciYML), 0o644)
44 }
45 os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644)
46 git(src, "add", ".")
47 git(src, "commit", "-q", "-m", "change")
48 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
49
50 dir := RepoDir(root, repo.OwnerName, repo.Name)
51 os.MkdirAll(filepath.Dir(dir), 0o755)
52 git(root, "clone", "-q", "--bare", src, dir)
53
54 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil {
55 t.Fatal(err)
56 }
57 mr, err := st.MRByNumber(repo.ID, 1)
58 if err != nil {
59 t.Fatal(err)
60 }
61
62 if seed {
63 if err := st.SetCommitStatus(repo.ID, targetSHA, "lint", "success", "", "", uid); err != nil {
64 t.Fatal(err)
65 }
66 }
67
68 g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA)
69 if err != nil {
70 t.Fatal(err)
71 }
72 return g
73}
74
75func checksUnmet(g GatesOut) bool {
76 for _, u := range g.Unmet {
77 if strings.Contains(u, "green checks") {
78 return true
79 }
80 }
81 return false
82}
83
84// A repository with require_checks on and no CI configuration at its head
85// can never report a status, so the gate has nothing to wait for and the
86// merge must go through rather than be refused forever.
87func TestRequireChecksPassesWithNoCIConfig(t *testing.T) {
88 if g := gatesForHead(t, ""); checksUnmet(g) {
89 t.Fatalf("refused a head with no CI configuration: %v", g.Unmet)
90 }
91}
92
93// Jobs that only run on a schedule or on tags never report on a merge
94// request head either.
95func TestRequireChecksPassesWithNoPushJobs(t *testing.T) {
96 cfg := "jobs:\n nightly:\n schedule: \"0 3 * * *\"\n steps:\n - echo hi\n" +
97 " release:\n tags: \"v*\"\n steps:\n - echo hi\n"
98 if g := gatesForHead(t, cfg); checksUnmet(g) {
99 t.Fatalf("refused a head whose jobs never run on a push: %v", g.Unmet)
100 }
101}
102
103// A push job at the head should have reported something. Silence there
104// means CI did not run, which is what the gate is for.
105func TestRequireChecksRefusesSilentPushJob(t *testing.T) {
106 cfg := "jobs:\n unit:\n steps:\n - echo hi\n"
107 if g := gatesForHead(t, cfg); !checksUnmet(g) {
108 t.Fatalf("allowed a head whose push job reported nothing: %v", g.Unmet)
109 }
110}
111
112// A repository whose checks come from outside — `status set`, no
113// .gitbay/ci.yml — looks like one with no CI at all. Having reported
114// before is what says a report was coming, so a silent head there is
115// still refused.
116func TestRequireChecksRefusesSilentHeadInReportingRepo(t *testing.T) {
117 if g := gatesForHeadSeeded(t, "", true); !checksUnmet(g) {
118 t.Fatalf("allowed a silent head in a repository that reports statuses: %v", g.Unmet)
119 }
120}
internal/control/mr.go +42 −3
@@ -9,6 +9,7 @@ import (
9 "strings" 9 "strings"
10 "time" 10 "time"
11 11
12 "gitbay.org/gitbay/internal/ci"
12 "gitbay.org/gitbay/internal/gitutil" 13 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/policy" 14 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol" 15 "gitbay.org/gitbay/internal/protocol"
@@ -1243,6 +1244,42 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
1243 return -1 1244 return -1
1244} 1245}
1245 1246
1247// checksExpected reports whether anything was going to report a status
1248// on this head. A repository with no CI configuration and no history of
1249// statuses can never satisfy require_checks, and refusing its merges
1250// leaves no remedy but turning the setting off. Two things say a report
1251// was coming: a .gitbay/ci.yml at the head with a job a push runs, and a
1252// status having ever been recorded in the repository, which is how a
1253// repository reporting from outside through `status set` looks.
1254func checksExpected(st *store.Store, repoID int64, dir, headSHA string) bool {
1255 if seen, err := st.RepoHasStatuses(repoID); err != nil || seen {
1256 return true
1257 }
1258 return headRunsJobs(dir, headSHA)
1259}
1260
1261// headRunsJobs reports whether a push of this head would have queued or
1262// skipped a job, and so left it a status. A configuration that will not
1263// parse counts as running jobs: the push recorded a ci/config failure
1264// for it, so the head is not silent and this is not the branch that
1265// decides.
1266func headRunsJobs(dir, headSHA string) bool {
1267 raw, err := gitutil.ReadBlob(dir, headSHA, ci.ConfigPath, 1<<16)
1268 if err != nil {
1269 return false
1270 }
1271 jobs, err := ci.Parse(raw)
1272 if err != nil {
1273 return true
1274 }
1275 for _, j := range jobs {
1276 if j.Tags == "" && j.Schedule == "" {
1277 return true
1278 }
1279 }
1280 return false
1281}
1282
1246// MergeGates computes where a merge request stands against its 1283// MergeGates computes where a merge request stands against its
1247// repository's gates: draft, require_checks, require_approvals (fresh, 1284// repository's gates: draft, require_checks, require_approvals (fresh,
1248// non-author, latest review per reviewer from someone who can write; a 1285// non-author, latest review per reviewer from someone who can write; a
@@ -1260,8 +1297,8 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
1260 g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number)) 1297 g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number))
1261 } 1298 }
1262 1299
1263 // Checks: with require_checks, the head must carry statuses and every 1300 // Checks: with require_checks, every status the head carries must be
1264 // one of them must be green. 1301 // green, and a head something was going to report on must carry some.
1265 statuses, err := st.ListCommitStatuses(repo.ID, headSHA) 1302 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
1266 if err != nil { 1303 if err != nil {
1267 return g, err 1304 return g, err
@@ -1271,7 +1308,9 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
1271 switch g.Checks { 1308 switch g.Checks {
1272 case "success": 1309 case "success":
1273 case "": 1310 case "":
1274 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)) 1311 if checksExpected(st, repo.ID, dir, headSHA) {
1312 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1313 }
1275 default: 1314 default:
1276 var bad []string 1315 var bad []string
1277 for _, st := range statuses { 1316 for _, st := range statuses {
internal/store/statuses.go +10
@@ -53,6 +53,16 @@ func (s *Store) ListCommitStatuses(repoID int64, sha string) ([]CommitStatus, er
53 return out, rows.Err() 53 return out, rows.Err()
54} 54}
55 55
56// RepoHasStatuses reports whether anything has ever reported a status in
57// this repository. It is how require_checks tells a repository whose
58// checks come from outside — `status set`, with no .gitbay/ci.yml — from
59// one that has no checks at all.
60func (s *Store) RepoHasStatuses(repoID int64) (bool, error) {
61 var n int
62 err := s.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM commit_statuses WHERE repo_id = ?)`, repoID).Scan(&n)
63 return n == 1, err
64}
65
56// CombinedStatus reduces per-context states to one: error/failure dominate, 66// CombinedStatus reduces per-context states to one: error/failure dominate,
57// then pending, then success; "" when no statuses exist. 67// then pending, then success; "" when no statuses exist.
58// CombinedStatusFor returns the combined state for each of several commits 68// CombinedStatusFor returns the combined state for each of several commits