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 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), | 434 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), |
| 435 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), | 435 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 436 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), | 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 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), | 438 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), |
| 438 | pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}), | 439 | pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}), |
| 439 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), | 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 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-approvals", "alice/svc", "1"); code != 0 { | 52 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-approvals", "alice/svc", "1"); code != 0 { |
| 53 | t.Fatal("require-approvals failed") | 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 | // No approvals: refused. The author's own approval does not count. | 59 | // No approvals: refused. The author's own approval does not count. |
| 57 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | 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 | 143 | // require_codeowners is the opt-in, not the file's presence: a repository |
| 141 | // require-approvals branch, so a repository with owners and the default | 144 | // can carry CODEOWNERS as documentation of who to ask without it gating |
| 142 | // settings had no owner gating at all (#99). | 145 | // merges. When it is on, it gates independently of require_approvals — |
| 143 | func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | 146 | // the coupling that left owners unenforced under default settings (#99). |
| 147 | func TestCodeownersToggle(t *testing.T) { | ||
| 144 | inst := startInstance(t) | 148 | inst := startInstance(t) |
| 145 | aliceKey := inst.newKey(t, "alice") | 149 | aliceKey := inst.newKey(t, "alice") |
| 146 | carolKey := inst.newKey(t, "carol") | 150 | carolKey := inst.newKey(t, "carol") |
| @@ -158,14 +162,15 @@ func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | |||
| 158 | dir := filepath.Join(work, "w") | 162 | dir := filepath.Join(work, "w") |
| 159 | os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644) | 163 | os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644) |
| 160 | os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644) | 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 | os.WriteFile(filepath.Join(dir, "README"), []byte("svc\n"), 0o644) | 166 | os.WriteFile(filepath.Join(dir, "README"), []byte("svc\n"), 0o644) |
| 162 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | 167 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") |
| 163 | mustGit(t, dir, env, "add", ".") | 168 | mustGit(t, dir, env, "add", ".") |
| 164 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | 169 | mustGit(t, dir, env, "commit", "-q", "-m", "base") |
| 165 | mustGit(t, dir, env, "push", "-q", "origin", "main") | 170 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 166 | 171 | ||
| 167 | // One MR touches an owned file, one does not. | 172 | // !1 and !3 touch owned files, !2 does not. |
| 168 | for i, f := range []string{"svc.go", "README"} { | 173 | for i, f := range []string{"svc.go", "README", "lib.go"} { |
| 169 | mustGit(t, dir, env, "checkout", "-q", "-b", fmt.Sprintf("feat%d", i), "main") | 174 | mustGit(t, dir, env, "checkout", "-q", "-b", fmt.Sprintf("feat%d", i), "main") |
| 170 | os.WriteFile(filepath.Join(dir, f), []byte("changed\n"), 0o644) | 175 | os.WriteFile(filepath.Join(dir, f), []byte("changed\n"), 0o644) |
| 171 | mustGit(t, dir, env, "add", ".") | 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 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | 196 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") |
| 182 | if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") { | 197 | if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") { |
| 183 | t.Fatalf("codeowners gate with require-approvals off: exit %d, %s", code, errOut) | 198 | t.Fatalf("codeowners gate with require-approvals off: exit %d, %s", code, errOut) |
| @@ -191,4 +206,32 @@ func TestCodeownersWithoutRequiredApprovals(t *testing.T) { | |||
| 191 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { | 206 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { |
| 192 | t.Fatalf("owner-approved merge refused: %s", errOut) | 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 | register(Command{Path: []string{"repo", "settings", "require-resolved"}, | 26 | register(Command{Path: []string{"repo", "settings", "require-resolved"}, |
| 27 | Summary: "require all review threads resolved to merge", | 27 | Summary: "require all review threads resolved to merge", |
| 28 | Usage: "repo settings require-resolved <owner/name> on|off", Run: runRequireResolved}) | 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 | register(Command{Path: []string{"repo", "settings", "require-checks"}, | 32 | register(Command{Path: []string{"repo", "settings", "require-checks"}, |
| 30 | Summary: "gate merges on green statuses", | 33 | Summary: "gate merges on green statuses", |
| 31 | Usage: "repo settings require-checks <owner/name> on|off", Run: runRequireChecks}) | 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 | func runRequireChecks(c *Ctx, args []string) int { | 182 | func runRequireChecks(c *Ctx, args []string) int { |
| 162 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { | 183 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 163 | return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off") | 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 | // reviewGates enforces require_approvals (fresh, non-author, latest review | 1042 | // reviewGates enforces require_approvals (fresh, non-author, latest review |
| 1022 | // per reviewer; a fresh request-changes blocks), CODEOWNERS coverage | 1043 | // per reviewer; a fresh request-changes blocks), require_codeowners, and |
| 1023 | // whenever the target branch carries a CODEOWNERS file, and | ||
| 1024 | // require_resolved. Returns -1 to proceed. | 1044 | // require_resolved. Returns -1 to proceed. |
| 1025 | func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { | 1045 | func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { |
| 1026 | set := repo.Settings | 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 | // CODEOWNERS: every owned changed file needs an approval from one of | 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 | 1083 | // its owners. require_codeowners is the opt-in — a repository can |
| 1064 | // require_approvals (#99). | 1084 | // carry the file as documentation of who to ask without it gating |
| 1065 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) | 1085 | // merges — and it does not wait on require_approvals (#99). |
| 1066 | if err != nil { | 1086 | if set.RequireCodeowners { |
| 1067 | content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) | 1087 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) |
| 1068 | } | 1088 | if err != nil { |
| 1069 | if err == nil && len(content) > 0 { | 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 | rules := policy.ParseCodeowners(string(content)) | 1096 | rules := policy.ParseCodeowners(string(content)) |
| 1071 | base, err := gitutil.MergeBase(dir, targetSHA, headSHA) | 1097 | base, err := gitutil.MergeBase(dir, targetSHA, headSHA) |
| 1072 | if err != nil { | 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 | argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))} | 68 | argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))} |
| 69 | case "require-resolved": | 69 | case "require-resolved": |
| 70 | argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))} | 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 | case "require-signed": | 73 | case "require-signed": |
| 72 | argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))} | 74 | argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))} |
| 73 | case "require-approvals": | 75 | case "require-approvals": |
internal/store/repos.go +1
| @@ -26,6 +26,7 @@ type RepoSettings struct { | |||
| 26 | RequireChecks bool `json:"require_checks,omitempty"` | 26 | RequireChecks bool `json:"require_checks,omitempty"` |
| 27 | RequireApprovals int `json:"require_approvals,omitempty"` | 27 | RequireApprovals int `json:"require_approvals,omitempty"` |
| 28 | RequireResolved bool `json:"require_resolved,omitempty"` | 28 | RequireResolved bool `json:"require_resolved,omitempty"` |
| 29 | RequireCodeowners bool `json:"require_codeowners,omitempty"` | ||
| 29 | GitDaemon bool `json:"git_daemon,omitempty"` | 30 | GitDaemon bool `json:"git_daemon,omitempty"` |
| 30 | Archived bool `json:"archived,omitempty"` | 31 | Archived bool `json:"archived,omitempty"` |
| 31 | Website string `json:"website,omitempty"` | 32 | Website string `json:"website,omitempty"` |
internal/web/templates/settings.html +6
| @@ -63,6 +63,12 @@ | |||
| 63 | <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> | 63 | <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> |
| 64 | <button type="submit">Save</button> | 64 | <button type="submit">Save</button> |
| 65 | </form> | 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 | <form method="post" action="{{$base}}" class="setform"> | 72 | <form method="post" action="{{$base}}" class="setform"> |
| 67 | <input type="hidden" name="field" value="require-signed"> | 73 | <input type="hidden" name="field" value="require-signed"> |
| 68 | <label for="require-signed">Require signed commits</label> | 74 | <label for="require-signed">Require signed commits</label> |