Commit 44533deae4

44533deae4e6a927124c80b8b0bfa373b53cc2b1

parent: 35867bdb7c

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-13 06:45 UTC

control: a repository that has reported before still waits

Checks posted through `status set` come from a repository with no
.gitbay/ci.yml, which the head's config alone cannot tell from a
repository with no checks at all. A status having ever been recorded in
the repository says a report was coming.

Ref #216
.gitbay/wiki/API.org +5 −3
@@ -110,9 +110,11 @@ worst of them. Statuses appear on commit pages, MR pages, and
110110=mr show=, each with =updated_at=; a =ci/<job>= status also carries
111111=duration=, read from the build behind it, once that build has
112112finished. With =repo settings require-checks <repo> on=, every status
113on the MR head must be green, and a head whose =.gitbay/ci.yml= has a
114job a push would run must carry some; a repository with no CI
115configuration, or only scheduled and tag jobs, merges. 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
116118report also emits a =status= event to webhooks.
117119
118120* Webhooks
internal/control/checksgate_test.go +22 −1
@@ -11,8 +11,13 @@ import (
1111
1212// gatesForHead builds a repository with require_checks on, a bare dir
1313// holding the given .gitbay/ci.yml (empty string for none), and one MR
14// whose head carries no statuses at all.
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.
1516func 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 {
1621 t.Helper()
1722 st, repo, uid := newQueueTestRepo(t)
1823 if _, err := st.UpdateRepoSettings(repo.ID, func(set *store.RepoSettings) { set.RequireChecks = true }); err != nil {
@@ -54,6 +59,12 @@ func gatesForHead(t *testing.T, ciYML string) GatesOut {
5459 t.Fatal(err)
5560 }
5661
62 if seed {
63 if err := st.SetCommitStatus(repo.ID, targetSHA, "lint", "success", "", "", uid); err != nil {
64 t.Fatal(err)
65 }
66 }
67
5768 g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA)
5869 if err != nil {
5970 t.Fatal(err)
@@ -97,3 +108,13 @@ func TestRequireChecksRefusesSilentPushJob(t *testing.T) {
97108 t.Fatalf("allowed a head whose push job reported nothing: %v", g.Unmet)
98109 }
99110}
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 +20 −8
@@ -1244,13 +1244,25 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
12441244 return -1
12451245}
12461246
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
12471261// headRunsJobs reports whether a push of this head would have queued or
1248// skipped a job, and so left it a status. A repository with no CI
1249// configuration, or one whose jobs all wait on a schedule or a tag, can
1250// never satisfy require_checks, and refusing its merges leaves no remedy
1251// but turning the setting off. A configuration that will not parse
1252// counts as running jobs: the push recorded a ci/config failure for it,
1253// so the head is not silent and this is not the branch that decides.
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.
12541266func headRunsJobs(dir, headSHA string) bool {
12551267 raw, err := gitutil.ReadBlob(dir, headSHA, ci.ConfigPath, 1<<16)
12561268 if err != nil {
@@ -1286,7 +1298,7 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
12861298 }
12871299
12881300 // Checks: with require_checks, every status the head carries must be
1289 // green, and a head whose CI would report must carry some.
1301 // green, and a head something was going to report on must carry some.
12901302 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
12911303 if err != nil {
12921304 return g, err
@@ -1296,7 +1308,7 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
12961308 switch g.Checks {
12971309 case "success":
12981310 case "":
1299 if headRunsJobs(dir, headSHA) {
1311 if checksExpected(st, repo.ID, dir, headSHA) {
13001312 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
13011313 }
13021314 default:
internal/store/statuses.go +10
@@ -53,6 +53,16 @@ func (s *Store) ListCommitStatuses(repoID int64, sha string) ([]CommitStatus, er
5353 return out, rows.Err()
5454}
5555
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
5666// CombinedStatus reduces per-context states to one: error/failure dominate,
5767// then pending, then success; "" when no statuses exist.
5868// CombinedStatusFor returns the combined state for each of several commits