Commit eee4509235
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/approvals_test.go +56
| @@ -136,3 +136,59 @@ func TestMergeRequirements(t *testing.T) { | ||
| 136 | 136 | t.Fatalf("stale approval counted: exit %d, %s", code, errOut) |
| 137 | 137 | } |
| 138 | 138 | } |
| 139 | ||
| 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) { | |
| 144 | inst := startInstance(t) | |
| 145 | aliceKey := inst.newKey(t, "alice") | |
| 146 | carolKey := inst.newKey(t, "carol") | |
| 147 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 148 | inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub") | |
| 149 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/svc"); code != 0 { | |
| 150 | t.Fatalf("repo create: %s", errOut) | |
| 151 | } | |
| 152 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/svc", "carol", "write"); code != 0 { | |
| 153 | t.Fatal("grant failed") | |
| 154 | } | |
| 155 | work := t.TempDir() | |
| 156 | env := inst.gitEnv(aliceKey) | |
| 157 | mustGit(t, work, env, "clone", inst.sshURL("alice/svc"), "w") | |
| 158 | dir := filepath.Join(work, "w") | |
| 159 | os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644) | |
| 160 | os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644) | |
| 161 | os.WriteFile(filepath.Join(dir, "README"), []byte("svc\n"), 0o644) | |
| 162 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 163 | mustGit(t, dir, env, "add", ".") | |
| 164 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 165 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 166 | ||
| 167 | // One MR touches an owned file, one does not. | |
| 168 | for i, f := range []string{"svc.go", "README"} { | |
| 169 | mustGit(t, dir, env, "checkout", "-q", "-b", fmt.Sprintf("feat%d", i), "main") | |
| 170 | os.WriteFile(filepath.Join(dir, f), []byte("changed\n"), 0o644) | |
| 171 | mustGit(t, dir, env, "add", ".") | |
| 172 | mustGit(t, dir, env, "commit", "-q", "-m", "change") | |
| 173 | mustGit(t, dir, env, "push", "-q", "origin", fmt.Sprintf("feat%d", i)) | |
| 174 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc", | |
| 175 | "--source", fmt.Sprintf("feat%d", i), "--target", "main", "--title", "'change'"); code != 0 { | |
| 176 | t.Fatalf("mr create: %s", errOut) | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | // Default settings, no approvals: the owned file is gated, the other is not. | |
| 181 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | |
| 182 | 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) | |
| 184 | } | |
| 185 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2"); code != 0 { | |
| 186 | t.Fatalf("unowned change refused: %s", errOut) | |
| 187 | } | |
| 188 | if _, _, code := inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 { | |
| 189 | t.Fatal("carol review failed") | |
| 190 | } | |
| 191 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { | |
| 192 | t.Fatalf("owner-approved merge refused: %s", errOut) | |
| 193 | } | |
| 194 | } | |
internal/control/mr.go +66 −67
| @@ -1145,37 +1145,35 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 1145 | 1145 | } |
| 1146 | 1146 | |
| 1147 | 1147 | // reviewGates enforces require_approvals (fresh, non-author, latest review |
| 1148 | // per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and | |
| 1148 | // per reviewer; a fresh request-changes blocks), CODEOWNERS coverage | |
| 1149 | // whenever the target branch carries a CODEOWNERS file, and | |
| 1149 | 1150 | // require_resolved. Returns -1 to proceed. |
| 1150 | 1151 | func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { |
| 1151 | 1152 | set := repo.Settings |
| 1152 | if set.RequireApprovals == 0 && !set.RequireResolved { | |
| 1153 | return -1 | |
| 1153 | reviews, err := c.Store.ListMRReviews(mr.ID) | |
| 1154 | if err != nil { | |
| 1155 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1154 | 1156 | } |
| 1155 | ||
| 1156 | if set.RequireApprovals > 0 { | |
| 1157 | reviews, err := c.Store.ListMRReviews(mr.ID) | |
| 1158 | if err != nil { | |
| 1159 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1160 | } | |
| 1161 | // Latest fresh review per reviewer decides their stance. | |
| 1162 | latest := map[string]string{} | |
| 1163 | for _, r := range reviews { | |
| 1164 | if r.Stale || r.Reviewer == mr.Author { | |
| 1165 | continue | |
| 1166 | } | |
| 1167 | latest[r.Reviewer] = r.Verdict | |
| 1157 | // Latest fresh review per reviewer decides their stance. | |
| 1158 | latest := map[string]string{} | |
| 1159 | for _, r := range reviews { | |
| 1160 | if r.Stale || r.Reviewer == mr.Author { | |
| 1161 | continue | |
| 1168 | 1162 | } |
| 1169 | var approvers []string | |
| 1170 | var blockers []string | |
| 1171 | for who, verdict := range latest { | |
| 1172 | switch verdict { | |
| 1173 | case "approve": | |
| 1174 | approvers = append(approvers, who) | |
| 1175 | case "request_changes": | |
| 1176 | blockers = append(blockers, who) | |
| 1177 | } | |
| 1163 | latest[r.Reviewer] = r.Verdict | |
| 1164 | } | |
| 1165 | var approvers []string | |
| 1166 | var blockers []string | |
| 1167 | for who, verdict := range latest { | |
| 1168 | switch verdict { | |
| 1169 | case "approve": | |
| 1170 | approvers = append(approvers, who) | |
| 1171 | case "request_changes": | |
| 1172 | blockers = append(blockers, who) | |
| 1178 | 1173 | } |
| 1174 | } | |
| 1175 | ||
| 1176 | if set.RequireApprovals > 0 { | |
| 1179 | 1177 | if len(blockers) > 0 { |
| 1180 | 1178 | slices.Sort(blockers) |
| 1181 | 1179 | return c.fail(protocol.ExitDenied, |
| @@ -1185,57 +1183,58 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA | ||
| 1185 | 1183 | return c.fail(protocol.ExitDenied, |
| 1186 | 1184 | "%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers)) |
| 1187 | 1185 | } |
| 1186 | } | |
| 1188 | 1187 | |
| 1189 | // CODEOWNERS: every owned changed file needs an approval from one | |
| 1190 | // of its owners. | |
| 1191 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) | |
| 1188 | // CODEOWNERS: every owned changed file needs an approval from one of | |
| 1189 | // its owners. The file's presence is the opt-in; it does not wait on | |
| 1190 | // require_approvals (#99). | |
| 1191 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) | |
| 1192 | if err != nil { | |
| 1193 | content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) | |
| 1194 | } | |
| 1195 | if err == nil && len(content) > 0 { | |
| 1196 | rules := policy.ParseCodeowners(string(content)) | |
| 1197 | base, err := gitutil.MergeBase(dir, targetSHA, headSHA) | |
| 1192 | 1198 | if err != nil { |
| 1193 | content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) | |
| 1199 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1194 | 1200 | } |
| 1195 | if err == nil && len(content) > 0 { | |
| 1196 | rules := policy.ParseCodeowners(string(content)) | |
| 1197 | base, err := gitutil.MergeBase(dir, targetSHA, headSHA) | |
| 1198 | if err != nil { | |
| 1199 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1200 | } | |
| 1201 | files, err := gitutil.DiffFiles(dir, base, headSHA) | |
| 1202 | if err != nil { | |
| 1203 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1204 | } | |
| 1205 | approved := map[string]bool{} | |
| 1206 | for _, a := range approvers { | |
| 1207 | approved[a] = true | |
| 1201 | files, err := gitutil.DiffFiles(dir, base, headSHA) | |
| 1202 | if err != nil { | |
| 1203 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1204 | } | |
| 1205 | approved := map[string]bool{} | |
| 1206 | for _, a := range approvers { | |
| 1207 | approved[a] = true | |
| 1208 | } | |
| 1209 | missing := map[string][]string{} // owner-set key -> example paths | |
| 1210 | for _, f := range files { | |
| 1211 | owners := policy.OwnersFor(rules, f) | |
| 1212 | if owners == nil { | |
| 1213 | continue | |
| 1208 | 1214 | } |
| 1209 | missing := map[string][]string{} // owner-set key -> example paths | |
| 1210 | for _, f := range files { | |
| 1211 | owners := policy.OwnersFor(rules, f) | |
| 1212 | if owners == nil { | |
| 1213 | continue | |
| 1214 | } | |
| 1215 | ok := false | |
| 1216 | for _, o := range owners { | |
| 1217 | if approved[o] { | |
| 1218 | ok = true | |
| 1219 | break | |
| 1220 | } | |
| 1221 | } | |
| 1222 | if !ok { | |
| 1223 | key := strings.Join(owners, ",") | |
| 1224 | if len(missing[key]) < 3 { | |
| 1225 | missing[key] = append(missing[key], f) | |
| 1226 | } | |
| 1215 | ok := false | |
| 1216 | for _, o := range owners { | |
| 1217 | if approved[o] { | |
| 1218 | ok = true | |
| 1219 | break | |
| 1227 | 1220 | } |
| 1228 | 1221 | } |
| 1229 | if len(missing) > 0 { | |
| 1230 | var parts []string | |
| 1231 | for owners, paths := range missing { | |
| 1232 | parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners)) | |
| 1222 | if !ok { | |
| 1223 | key := strings.Join(owners, ",") | |
| 1224 | if len(missing[key]) < 3 { | |
| 1225 | missing[key] = append(missing[key], f) | |
| 1233 | 1226 | } |
| 1234 | slices.Sort(parts) | |
| 1235 | return c.fail(protocol.ExitDenied, | |
| 1236 | "CODEOWNERS approval missing for: %s", strings.Join(parts, "; ")) | |
| 1237 | 1227 | } |
| 1238 | 1228 | } |
| 1229 | if len(missing) > 0 { | |
| 1230 | var parts []string | |
| 1231 | for owners, paths := range missing { | |
| 1232 | parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners)) | |
| 1233 | } | |
| 1234 | slices.Sort(parts) | |
| 1235 | return c.fail(protocol.ExitDenied, | |
| 1236 | "CODEOWNERS approval missing for: %s", strings.Join(parts, "; ")) | |
| 1237 | } | |
| 1239 | 1238 | } |
| 1240 | 1239 | |
| 1241 | 1240 | if set.RequireResolved { |