Commit 1f8076c655
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +1
| @@ -434,6 +434,7 @@ func repoCmd() *cobra.Command { | ||
| 434 | 434 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), |
| 435 | 435 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 436 | 436 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), |
| 437 | pass("require-codeowners", "require an owner's approval per covered file: on|off", passOpts{server: []string{"repo", "settings", "require-codeowners"}, needsRepo: true}), | |
| 437 | 438 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), |
| 438 | 439 | pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}), |
| 439 | 440 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), |
e2e/approvals_test.go +50 −7
| @@ -52,6 +52,9 @@ func TestMergeRequirements(t *testing.T) { | ||
| 52 | 52 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-approvals", "alice/svc", "1"); code != 0 { |
| 53 | 53 | t.Fatal("require-approvals failed") |
| 54 | 54 | } |
| 55 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/svc", "on"); code != 0 { | |
| 56 | t.Fatal("require-codeowners failed") | |
| 57 | } | |
| 55 | 58 | |
| 56 | 59 | // No approvals: refused. The author's own approval does not count. |
| 57 | 60 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") |
| @@ -137,10 +140,11 @@ func TestMergeRequirements(t *testing.T) { | ||
| 137 | 140 | } |
| 138 | 141 | } |
| 139 | 142 | |
| 140 | // A CODEOWNERS file gates on its own. It used to be read only inside the | |
| 141 | // require-approvals branch, so a repository with owners and the default | |
| 142 | // settings had no owner gating at all (#99). | |
| 143 | func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | |
| 143 | // require_codeowners is the opt-in, not the file's presence: a repository | |
| 144 | // can carry CODEOWNERS as documentation of who to ask without it gating | |
| 145 | // merges. When it is on, it gates independently of require_approvals — | |
| 146 | // the coupling that left owners unenforced under default settings (#99). | |
| 147 | func TestCodeownersToggle(t *testing.T) { | |
| 144 | 148 | inst := startInstance(t) |
| 145 | 149 | aliceKey := inst.newKey(t, "alice") |
| 146 | 150 | carolKey := inst.newKey(t, "carol") |
| @@ -158,14 +162,15 @@ func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | ||
| 158 | 162 | dir := filepath.Join(work, "w") |
| 159 | 163 | os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644) |
| 160 | 164 | os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644) |
| 165 | os.WriteFile(filepath.Join(dir, "lib.go"), []byte("package svc\n"), 0o644) | |
| 161 | 166 | os.WriteFile(filepath.Join(dir, "README"), []byte("svc\n"), 0o644) |
| 162 | 167 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") |
| 163 | 168 | mustGit(t, dir, env, "add", ".") |
| 164 | 169 | mustGit(t, dir, env, "commit", "-q", "-m", "base") |
| 165 | 170 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 166 | 171 | |
| 167 | // One MR touches an owned file, one does not. | |
| 168 | for i, f := range []string{"svc.go", "README"} { | |
| 172 | // !1 and !3 touch owned files, !2 does not. | |
| 173 | for i, f := range []string{"svc.go", "README", "lib.go"} { | |
| 169 | 174 | mustGit(t, dir, env, "checkout", "-q", "-b", fmt.Sprintf("feat%d", i), "main") |
| 170 | 175 | os.WriteFile(filepath.Join(dir, f), []byte("changed\n"), 0o644) |
| 171 | 176 | mustGit(t, dir, env, "add", ".") |
| @@ -177,7 +182,17 @@ func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | ||
| 177 | 182 | } |
| 178 | 183 | } |
| 179 | 184 | |
| 180 | // Default settings, no approvals: the owned file is gated, the other is not. | |
| 185 | // Toggle off, which is the default: the file is present and gates | |
| 186 | // nothing, so an owned file merges without its owner. | |
| 187 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "3"); code != 0 { | |
| 188 | t.Fatalf("owned file gated with the toggle off: %s", errOut) | |
| 189 | } | |
| 190 | ||
| 191 | // Toggle on with require_approvals still 0: the owned file is gated, | |
| 192 | // the unowned one is not. | |
| 193 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/svc", "on"); code != 0 { | |
| 194 | t.Fatalf("require-codeowners: %s", errOut) | |
| 195 | } | |
| 181 | 196 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") |
| 182 | 197 | if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") { |
| 183 | 198 | t.Fatalf("codeowners gate with require-approvals off: exit %d, %s", code, errOut) |
| @@ -191,4 +206,32 @@ func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | ||
| 191 | 206 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { |
| 192 | 207 | t.Fatalf("owner-approved merge refused: %s", errOut) |
| 193 | 208 | } |
| 209 | ||
| 210 | // The toggle on a repository with no CODEOWNERS file says so rather | |
| 211 | // than silently gating nothing. | |
| 212 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/bare"); code != 0 { | |
| 213 | t.Fatalf("repo create: %s", errOut) | |
| 214 | } | |
| 215 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/bare", "on"); code != 0 { | |
| 216 | t.Fatal("require-codeowners on alice/bare failed") | |
| 217 | } | |
| 218 | bare := t.TempDir() | |
| 219 | mustGit(t, bare, env, "clone", inst.sshURL("alice/bare"), "b") | |
| 220 | bdir := filepath.Join(bare, "b") | |
| 221 | os.WriteFile(filepath.Join(bdir, "a.txt"), []byte("a\n"), 0o644) | |
| 222 | mustGit(t, bdir, env, "checkout", "-q", "-b", "main") | |
| 223 | mustGit(t, bdir, env, "add", ".") | |
| 224 | mustGit(t, bdir, env, "commit", "-q", "-m", "base") | |
| 225 | mustGit(t, bdir, env, "push", "-q", "origin", "main") | |
| 226 | mustGit(t, bdir, env, "checkout", "-q", "-b", "feat") | |
| 227 | mustGit(t, bdir, env, "commit", "-q", "--allow-empty", "-m", "work") | |
| 228 | mustGit(t, bdir, env, "push", "-q", "origin", "feat") | |
| 229 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/bare", | |
| 230 | "--source", "feat", "--target", "main", "--title", "'work'"); code != 0 { | |
| 231 | t.Fatalf("mr create: %s", errOut) | |
| 232 | } | |
| 233 | _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/bare", "1") | |
| 234 | if code != 4 || !strings.Contains(errOut, "no CODEOWNERS file") { | |
| 235 | t.Fatalf("missing CODEOWNERS file: exit %d, %s", code, errOut) | |
| 236 | } | |
| 194 | 237 | } |
internal/control/mr.go +35 −9
| @@ -26,6 +26,9 @@ func init() { | ||
| 26 | 26 | register(Command{Path: []string{"repo", "settings", "require-resolved"}, |
| 27 | 27 | Summary: "require all review threads resolved to merge", |
| 28 | 28 | Usage: "repo settings require-resolved <owner/name> on|off", Run: runRequireResolved}) |
| 29 | register(Command{Path: []string{"repo", "settings", "require-codeowners"}, | |
| 30 | Summary: "require an owner's approval for every file CODEOWNERS covers", | |
| 31 | Usage: "repo settings require-codeowners <owner/name> on|off", Run: runRequireCodeowners}) | |
| 29 | 32 | register(Command{Path: []string{"repo", "settings", "require-checks"}, |
| 30 | 33 | Summary: "gate merges on green statuses", |
| 31 | 34 | Usage: "repo settings require-checks <owner/name> on|off", Run: runRequireChecks}) |
| @@ -158,6 +161,24 @@ func runRequireResolved(c *Ctx, args []string) int { | ||
| 158 | 161 | }) |
| 159 | 162 | } |
| 160 | 163 | |
| 164 | func runRequireCodeowners(c *Ctx, args []string) int { | |
| 165 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { | |
| 166 | return c.fail(protocol.ExitUsage, "usage: repo settings require-codeowners <owner/name> on|off") | |
| 167 | } | |
| 168 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 169 | if code >= 0 { | |
| 170 | return code | |
| 171 | } | |
| 172 | s := repo.Settings | |
| 173 | s.RequireCodeowners = args[1] == "on" | |
| 174 | if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { | |
| 175 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 176 | } | |
| 177 | return c.emit(s, func(w io.Writer) { | |
| 178 | fmt.Fprintf(w, "require_codeowners %s on %s\n", args[1], repo.Path()) | |
| 179 | }) | |
| 180 | } | |
| 181 | ||
| 161 | 182 | func runRequireChecks(c *Ctx, args []string) int { |
| 162 | 183 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 163 | 184 | return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off") |
| @@ -1019,8 +1040,7 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 1019 | 1040 | } |
| 1020 | 1041 | |
| 1021 | 1042 | // reviewGates enforces require_approvals (fresh, non-author, latest review |
| 1022 | // per reviewer; a fresh request-changes blocks), CODEOWNERS coverage | |
| 1023 | // whenever the target branch carries a CODEOWNERS file, and | |
| 1043 | // per reviewer; a fresh request-changes blocks), require_codeowners, and | |
| 1024 | 1044 | // require_resolved. Returns -1 to proceed. |
| 1025 | 1045 | func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { |
| 1026 | 1046 | set := repo.Settings |
| @@ -1060,13 +1080,19 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA | ||
| 1060 | 1080 | } |
| 1061 | 1081 | |
| 1062 | 1082 | // CODEOWNERS: every owned changed file needs an approval from one of |
| 1063 | // its owners. The file's presence is the opt-in; it does not wait on | |
| 1064 | // require_approvals (#99). | |
| 1065 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) | |
| 1066 | if err != nil { | |
| 1067 | content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) | |
| 1068 | } | |
| 1069 | if err == nil && len(content) > 0 { | |
| 1083 | // its owners. require_codeowners is the opt-in — a repository can | |
| 1084 | // carry the file as documentation of who to ask without it gating | |
| 1085 | // merges — and it does not wait on require_approvals (#99). | |
| 1086 | if set.RequireCodeowners { | |
| 1087 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) | |
| 1088 | if err != nil { | |
| 1089 | content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) | |
| 1090 | } | |
| 1091 | if err != nil || len(content) == 0 { | |
| 1092 | return c.fail(protocol.ExitDenied, | |
| 1093 | "%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", | |
| 1094 | repo.Path(), mr.TargetRef) | |
| 1095 | } | |
| 1070 | 1096 | rules := policy.ParseCodeowners(string(content)) |
| 1071 | 1097 | base, err := gitutil.MergeBase(dir, targetSHA, headSHA) |
| 1072 | 1098 | if err != nil { |
internal/httpd/settings.go +2
| @@ -68,6 +68,8 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 68 | 68 | argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))} |
| 69 | 69 | case "require-resolved": |
| 70 | 70 | argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))} |
| 71 | case "require-codeowners": | |
| 72 | argv = []string{"repo", "settings", "require-codeowners", repo, onOff(v("require-codeowners"))} | |
| 71 | 73 | case "require-signed": |
| 72 | 74 | argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))} |
| 73 | 75 | case "require-approvals": |
internal/store/repos.go +1
| @@ -26,6 +26,7 @@ type RepoSettings struct { | ||
| 26 | 26 | RequireChecks bool `json:"require_checks,omitempty"` |
| 27 | 27 | RequireApprovals int `json:"require_approvals,omitempty"` |
| 28 | 28 | RequireResolved bool `json:"require_resolved,omitempty"` |
| 29 | RequireCodeowners bool `json:"require_codeowners,omitempty"` | |
| 29 | 30 | GitDaemon bool `json:"git_daemon,omitempty"` |
| 30 | 31 | Archived bool `json:"archived,omitempty"` |
| 31 | 32 | Website string `json:"website,omitempty"` |
internal/web/templates/settings.html +6
| @@ -63,6 +63,12 @@ | ||
| 63 | 63 | <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> |
| 64 | 64 | <button type="submit">Save</button> |
| 65 | 65 | </form> |
| 66 | <form method="post" action="{{$base}}" class="setform"> | |
| 67 | <input type="hidden" name="field" value="require-codeowners"> | |
| 68 | <label for="require-codeowners">Require CODEOWNERS approval</label> | |
| 69 | <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}> | |
| 70 | <button type="submit">Save</button> | |
| 71 | </form> | |
| 66 | 72 | <form method="post" action="{{$base}}" class="setform"> |
| 67 | 73 | <input type="hidden" name="field" value="require-signed"> |
| 68 | 74 | <label for="require-signed">Require signed commits</label> |