Commit dc10160221

dc10160221836dabdd5ef8febe0b4f7f13051041

parent: b1f4bf1aaf

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:51 UTC

repo settings: required contexts turn the checks gate on, pending until reported

Ref #258

Layout: unified · split

cmd/gitbay/main.go +1
@@ -618,6 +618,7 @@ func repoCmd() *cobra.Command {
618618 pass("require-resolved", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
619619 pass("require-codeowners", passOpts{server: []string{"repo", "settings", "require-codeowners"}, needsRepo: true}),
620620 pass("require-checks", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
621 pass("require-contexts", passOpts{server: []string{"repo", "settings", "require-contexts"}, needsRepo: true}),
621622 pass("visibility", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}),
622623 pass("require-signed", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
623624 pass("require-mr", passOpts{server: []string{"repo", "settings", "require-mr"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +1
@@ -187,6 +187,7 @@ var summaries = map[string]string{
187187 "repo settings require-approvals": "require N fresh approvals to merge",
188188 "repo settings require-checks": "gate merges on green statuses",
189189 "repo settings require-codeowners": "require an owner's approval for every file CODEOWNERS covers",
190 "repo settings require-contexts": "name the statuses the checks gate waits for, and turn the gate on",
190191 "repo settings require-mr": "protected branches take changes through merge requests only",
191192 "repo settings require-resolved": "require all review threads resolved to merge",
192193 "repo settings require-signed": "require verified commit signatures",
e2e/settingsweb_test.go +7 −2
@@ -55,7 +55,12 @@ func TestRepoSettingsWeb(t *testing.T) {
5555
5656 post(url.Values{"field": {"description"}, "description": {"a fine tool"}})
5757 post(url.Values{"field": {"website"}, "website": {"https://tool.example"}})
58 post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}})
58 // Saving required contexts turns the checks gate on, and the page
59 // shows it ticked with the contexts in its hint (#258).
60 if body := post(url.Values{"field": {"require-contexts"}, "contexts": {"ext/deploy lint"}}); !strings.Contains(body, `id="require-checks" name="require-checks" value="on" checked`) ||
61 !strings.Contains(body, `Also waits for <code>ext/deploy</code>, <code>lint</code> until they report.`) {
62 t.Fatalf("required contexts did not show as turning required checks on:\n%s", body)
63 }
5964 post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}})
6065 post(url.Values{"field": {"protect"}, "branch": {"main"}})
6166
@@ -66,7 +71,7 @@ func TestRepoSettingsWeb(t *testing.T) {
6671 }
6772 }
6873 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json")
69 for _, want := range []string{`"require_checks":true`, `"require_approvals":2`, `"main"`} {
74 for _, want := range []string{`"require_checks":true`, `"required_contexts":["ext/deploy","lint"]`, `"require_approvals":2`, `"main"`} {
7075 if !strings.Contains(out, want) {
7176 t.Fatalf("settings show missing %q:\n%s", want, out)
7277 }
internal/control/checksgate_test.go +55 −7
@@ -3,6 +3,7 @@ package control
33import (
44 "os"
55 "path/filepath"
6 "slices"
67 "strings"
78 "testing"
89
@@ -18,9 +19,30 @@ func gatesForHead(t *testing.T, ciYML string) GatesOut {
1819}
1920
2021func 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 {
2138 t.Helper()
2239 st, repo, uid := newQueueTestRepo(t)
23 if _, err := st.UpdateRepoSettings(repo.ID, func(set *store.RepoSettings) { set.RequireChecks = true }); err != nil {
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 {
2446 t.Fatal(err)
2547 }
2648 repo, err := st.RepoByID(repo.ID)
@@ -58,13 +80,9 @@ func gatesForHeadSeeded(t *testing.T, ciYML string, seed bool) GatesOut {
5880 if err != nil {
5981 t.Fatal(err)
6082 }
61
62 if seed {
63 if err := st.SetCommitStatus(repo.ID, targetSHA, "lint", "success", "", "", uid); err != nil {
64 t.Fatal(err)
65 }
83 if seed != nil {
84 seed(st, repo.ID, uid, targetSHA, headSHA)
6685 }
67
6886 g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA)
6987 if err != nil {
7088 t.Fatal(err)
@@ -118,3 +136,33 @@ func TestRequireChecksRefusesSilentHeadInReportingRepo(t *testing.T) {
118136 t.Fatalf("allowed a silent head in a repository that reports statuses: %v", g.Unmet)
119137 }
120138}
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}
internal/control/mr.go +72 −1
@@ -47,6 +47,11 @@ func init() {
4747 Usage: "repo settings require-checks <owner/name> on|off",
4848 Examples: []string{"repo settings require-checks krz/gitbay on"},
4949 Run: runRequireChecks})
50 register(Command{Path: []string{"repo", "settings", "require-contexts"},
51 Summary: "name the statuses the checks gate waits for, and turn the gate on",
52 Usage: "repo settings require-contexts <owner/name> [<context>...] (none clears the list)",
53 Examples: []string{"repo settings require-contexts krz/gitbay ci/build ci/test"},
54 Run: runRequireContexts})
5055 register(Command{Path: []string{"repo", "settings", "require-mr"},
5156 Summary: "protected branches take changes through merge requests only",
5257 Usage: "repo settings require-mr <owner/name> on|off",
@@ -340,6 +345,54 @@ func runRequireChecks(c *Ctx, args []string) int {
340345 })
341346}
342347
348// maxRequiredContexts bounds the list: a gate naming more checks than
349// this is a configuration mistake.
350const maxRequiredContexts = 20
351
352func runRequireContexts(c *Ctx, args []string) int {
353 if len(args) < 1 {
354 return c.usage()
355 }
356 var contexts []string
357 for _, ctx := range args[1:] {
358 if ctx == "" || len(ctx) > 100 || strings.ContainsAny(ctx, " \t\r\n") {
359 return c.fail(protocol.ExitUsage, "a context is 1 to 100 characters with no whitespace: %q", ctx)
360 }
361 if !slices.Contains(contexts, ctx) {
362 contexts = append(contexts, ctx)
363 }
364 }
365 if len(contexts) > maxRequiredContexts {
366 return c.fail(protocol.ExitUsage, "at most %d required contexts", maxRequiredContexts)
367 }
368 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
369 if code >= 0 {
370 return code
371 }
372 // Naming contexts asks for the gate, so it turns require_checks on in
373 // the same update. Clearing the list leaves the gate as it was.
374 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
375 s.RequiredContexts = contexts
376 if len(contexts) > 0 {
377 s.RequireChecks = true
378 }
379 })
380 if err != nil {
381 return c.fail(protocol.ExitFailure, "%v", err)
382 }
383 return c.emit(s, func(w io.Writer) {
384 if len(contexts) > 0 {
385 fmt.Fprintf(w, "required contexts on %s: %s; require_checks on\n", repo.Path(), strings.Join(contexts, ", "))
386 return
387 }
388 gate := "off"
389 if s.RequireChecks {
390 gate = "on"
391 }
392 fmt.Fprintf(w, "required contexts cleared on %s; require_checks %s\n", repo.Path(), gate)
393 })
394}
395
343396func runRequireMR(c *Ctx, args []string) int {
344397 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
345398 return c.usage()
@@ -1577,13 +1630,28 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
15771630 }
15781631
15791632 // Checks: with require_checks, every status the head carries must be
1580 // green, and a head something was going to report on must carry some.
1633 // green, a head something was going to report on must carry some, and
1634 // every required context must have reported: one that has not is
1635 // pending whatever the others say (#258). Setting contexts turns
1636 // require_checks on; turned off again, the list is kept and unread.
15811637 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
15821638 if err != nil {
15831639 return g, err
15841640 }
15851641 g.Checks = store.CombinedStatus(statuses)
15861642 if set.RequireChecks {
1643 reported := map[string]bool{}
1644 for _, s := range statuses {
1645 reported[s.Context] = true
1646 }
1647 for _, want := range set.RequiredContexts {
1648 if !reported[want] {
1649 g.ChecksMissing = append(g.ChecksMissing, want)
1650 }
1651 }
1652 if len(g.ChecksMissing) > 0 && (g.Checks == "" || g.Checks == "success") {
1653 g.Checks = "pending"
1654 }
15871655 switch g.Checks {
15881656 case "success":
15891657 case "":
@@ -1597,6 +1665,9 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
15971665 bad = append(bad, st.Context+"="+st.State)
15981666 }
15991667 }
1668 for _, m := range g.ChecksMissing {
1669 bad = append(bad, m+"=missing")
1670 }
16001671 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")))
16011672 }
16021673 }
internal/control/mr_test.go +75
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "os"
77 "path/filepath"
8 "slices"
89 "strconv"
910 "strings"
1011 "testing"
@@ -262,3 +263,77 @@ func TestMRShowPluralizesMultiRowSections(t *testing.T) {
262263 }
263264 }
264265}
266
267// require-contexts stores a deduplicated list and turns require_checks
268// on with it; an empty list clears the contexts and leaves
269// require_checks as it was. A context with whitespace is refused (#258).
270func TestRequireContextsSetsAndClears(t *testing.T) {
271 st, repo, uid := newQueueTestRepo(t)
272 alice := store.User{ID: uid, Username: "alice"}
273 dispatch := func(args ...string) int {
274 t.Helper()
275 c, _, _ := mrTestCtx(st, alice)
276 return Dispatch(c, args)
277 }
278 contexts := func(names ...string) int {
279 t.Helper()
280 return dispatch(append([]string{"repo", "settings", "require-contexts", repo.Path()}, names...)...)
281 }
282 settings := func() store.RepoSettings {
283 t.Helper()
284 got, err := st.RepoByID(repo.ID)
285 if err != nil {
286 t.Fatal(err)
287 }
288 return got.Settings
289 }
290
291 if settings().RequireChecks {
292 t.Fatal("require_checks on in a new repository")
293 }
294 if code := contexts("lint", "ext/deploy", "lint"); code != protocol.ExitOK {
295 t.Fatalf("set: exit %d", code)
296 }
297 if s := settings(); !slices.Equal(s.RequiredContexts, []string{"lint", "ext/deploy"}) || !s.RequireChecks {
298 t.Fatalf("stored %v, require_checks %v; want [lint ext/deploy], on", s.RequiredContexts, s.RequireChecks)
299 }
300 if code := contexts("bad context"); code != protocol.ExitUsage {
301 t.Fatalf("a context with a space: exit %d", code)
302 }
303 if code := contexts(); code != protocol.ExitOK {
304 t.Fatalf("clear: exit %d", code)
305 }
306 if s := settings(); len(s.RequiredContexts) != 0 || !s.RequireChecks {
307 t.Fatalf("after clearing: contexts %v, require_checks %v; want none, still on", s.RequiredContexts, s.RequireChecks)
308 }
309 if code := dispatch("repo", "settings", "require-checks", repo.Path(), "off"); code != protocol.ExitOK {
310 t.Fatalf("require-checks off: exit %d", code)
311 }
312 if code := contexts(); code != protocol.ExitOK {
313 t.Fatalf("clear again: exit %d", code)
314 }
315 if settings().RequireChecks {
316 t.Fatal("clearing the list turned require_checks on")
317 }
318}
319
320// settings show prints the checks gate beside the contexts it waits for,
321// so a list that turned the gate on is visible where the gate is (#258).
322func TestSettingsShowRequiredContexts(t *testing.T) {
323 st, repo, uid := newQueueTestRepo(t)
324 alice := store.User{ID: uid, Username: "alice"}
325 c, _, _ := mrTestCtx(st, alice)
326 if code := Dispatch(c, []string{"repo", "settings", "require-contexts", repo.Path(), "ext/deploy", "lint"}); code != protocol.ExitOK {
327 t.Fatalf("require-contexts: exit %d", code)
328 }
329 c, out, _ := mrTestCtx(st, alice)
330 if code := Dispatch(c, []string{"repo", "settings", "show", repo.Path()}); code != protocol.ExitOK {
331 t.Fatalf("settings show: exit %d", code)
332 }
333 got := strings.Join(strings.Fields(out.String()), " ")
334 for _, want := range []string{"require checks true", "required contexts ext/deploy, lint"} {
335 if !strings.Contains(got, want) {
336 t.Errorf("settings show lacks %q:\n%s", want, out.String())
337 }
338 }
339}
internal/control/output.go +2 −1
@@ -57,7 +57,8 @@ type GatesOut struct {
5757 ResolvedRequired bool `json:"resolved_required"`
5858 OpenThreads int `json:"open_threads"`
5959 ChecksRequired bool `json:"checks_required"`
60 Checks string `json:"checks,omitempty"` // combined status; "" when none reported
60 Checks string `json:"checks,omitempty"` // combined status; "" when none reported
61 ChecksMissing []string `json:"checks_missing,omitempty"` // required contexts not reported
6162 FastForward bool `json:"fast_forward"`
6263 Unmet []string `json:"unmet,omitempty"`
6364}
internal/control/repo.go +2
@@ -711,6 +711,8 @@ func runSettingsShow(c *Ctx, args []string) int {
711711 "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "),
712712 "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "),
713713 "require mr", strconv.FormatBool(repo.Settings.RequireMR),
714 "require checks", strconv.FormatBool(repo.Settings.RequireChecks),
715 "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "),
714716 "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits),
715717 "git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
716718 "archived", strconv.FormatBool(repo.Settings.Archived),
internal/httpd/mrpage_test.go +3
@@ -195,6 +195,9 @@ func TestMRGatesRender(t *testing.T) {
195195 if out := render(&control.GatesOut{FastForward: true}); !strings.Contains(out, "All gates met") || !strings.Contains(out, "fast-forward possible") {
196196 t.Errorf("met gates not rendered:\n%s", out)
197197 }
198 if out := render(&control.GatesOut{Checks: "pending", ChecksMissing: []string{"ext/deploy"}}); !strings.Contains(out, "waiting on <code>ext/deploy</code>") {
199 t.Errorf("missing required context not rendered:\n%s", out)
200 }
198201 if out := render(nil); strings.Contains(out, "Merge gates") {
199202 t.Errorf("gates block on a merge request without gates:\n%s", out)
200203 }
internal/httpd/settings.go +4
@@ -105,6 +105,8 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
105105 argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))}
106106 case "require-checks":
107107 argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))}
108 case "require-contexts":
109 argv = append([]string{"repo", "settings", "require-contexts", repo}, strings.Fields(v("contexts"))...)
108110 case "require-resolved":
109111 argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))}
110112 case "require-codeowners":
@@ -227,6 +229,8 @@ func fieldLabel(field string) string {
227229 return "git:// serving"
228230 case "require-checks":
229231 return "required checks"
232 case "require-contexts":
233 return "required contexts"
230234 case "require-approvals":
231235 return "approvals"
232236 case "require-resolved":
internal/store/repos.go +11 −7
@@ -27,13 +27,17 @@ type RepoSettings struct {
2727 ProtectedTags []string `json:"protected_tags,omitempty"` // path.Match globs
2828 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
2929 RequireChecks bool `json:"require_checks,omitempty"`
30 RequireApprovals int `json:"require_approvals,omitempty"`
31 RequireResolved bool `json:"require_resolved,omitempty"`
32 RequireCodeowners bool `json:"require_codeowners,omitempty"`
33 RequireMR bool `json:"require_mr,omitempty"`
34 GitDaemon bool `json:"git_daemon,omitempty"`
35 Archived bool `json:"archived,omitempty"`
36 Website string `json:"website,omitempty"`
30 // RequiredContexts are statuses require_checks waits for whether or
31 // not they have reported; one that has not is pending. Setting a
32 // non-empty list turns RequireChecks on (#258).
33 RequiredContexts []string `json:"required_contexts,omitempty"`
34 RequireApprovals int `json:"require_approvals,omitempty"`
35 RequireResolved bool `json:"require_resolved,omitempty"`
36 RequireCodeowners bool `json:"require_codeowners,omitempty"`
37 RequireMR bool `json:"require_mr,omitempty"`
38 GitDaemon bool `json:"git_daemon,omitempty"`
39 Archived bool `json:"archived,omitempty"`
40 Website string `json:"website,omitempty"`
3741}
3842
3943// Path returns the canonical owner/name form.
internal/web/templates/mr.html +1
@@ -122,6 +122,7 @@
122122 {{else}}<p class="row"><span class="dot ok"></span>All gates met</p>{{end}}
123123 {{if .ApprovalsRequired}}<p class="row none">approvals: {{len .Approvals}} of {{.ApprovalsRequired}}{{if .Approvals}} ({{range $i, $a := .Approvals}}{{if $i}}, {{end}}{{$a}}{{end}}){{end}}</p>{{end}}
124124 {{range .OwnersOutstanding}}<p class="row none">waiting on {{range $i, $o := .Owners}}{{if $i}} or {{end}}<a href="/{{$o}}">{{$o}}</a>{{end}} for {{range $i, $f := .Files}}{{if $i}}, {{end}}<code>{{$f}}</code>{{end}}</p>{{end}}
125 {{range .ChecksMissing}}<p class="row none">waiting on <code>{{.}}</code>, not yet reported</p>{{end}}
125126 <p class="row none">{{if .FastForward}}fast-forward possible{{else}}not a fast-forward: rebase, or merge with a merge commit{{end}}</p>
126127 </div>{{end}}
127128 <div class="grp">
internal/web/templates/settings.html +7 −1
@@ -72,10 +72,16 @@
7272<p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p>
7373<form method="post" action="{{$base}}" class="setform">
7474 <input type="hidden" name="field" value="require-checks">
75 <div><label for="require-checks">Required checks</label><p class="hint">Requires CI to succeed.</p></div>
75 <div><label for="require-checks">Required checks</label><p class="hint">Requires CI to succeed.{{with .Repo.Settings.RequiredContexts}} Also waits for {{range $i, $c := .}}{{if $i}}, {{end}}<code>{{$c}}</code>{{end}} until they report{{if not $.Repo.Settings.RequireChecks}}, once this is on{{end}}.{{end}}</p></div>
7676 <div class="check"><input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}></div>
7777 <div><button type="submit" class="btn">Save</button></div>
7878</form>
79<form method="post" action="{{$base}}" class="setform">
80 <input type="hidden" name="field" value="require-contexts">
81 <div><label for="contexts">Required contexts</label><p class="hint">Statuses the checks gate waits for until they report, separated by spaces. Saving any turns required checks on; saving none leaves it as it is.</p></div>
82 <div><input type="text" id="contexts" name="contexts" value="{{range $i, $c := .Repo.Settings.RequiredContexts}}{{if $i}} {{end}}{{$c}}{{end}}" autocomplete="off"></div>
83 <div><button type="submit" class="btn">Save</button></div>
84</form>
7985<form method="post" action="{{$base}}" class="setform">
8086 <input type="hidden" name="field" value="require-approvals">
8187 <div><label for="approvals">Approvals</label><p class="hint">Approvals from anyone with write access. Zero means none required.</p></div>