Commit dde9591208
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -232,6 +232,8 @@ func repoCmd() *cobra.Command { | |||
| 232 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), | 232 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 233 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), | 233 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 234 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), | 234 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), |
| 235 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), | ||
| 236 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), | ||
| 235 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), | 237 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), |
| 236 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), | 238 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), |
| 237 | pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), | 239 | pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), |
docs/users.org +8
| @@ -166,6 +166,14 @@ Semantics worth knowing: | |||
| 166 | verified commits merge; everything server-created is refused with | 166 | verified commits merge; everything server-created is refused with |
| 167 | instructions to rebase locally. | 167 | instructions to rebase locally. |
| 168 | 168 | ||
| 169 | Repo admins can gate merges (=repo settings ...=): =require-approvals | ||
| 170 | <n>= (fresh, non-author approvals; each reviewer's latest review is | ||
| 171 | their stance, and a fresh request-changes blocks), =require-resolved= | ||
| 172 | (no open review threads), =require-checks= (all statuses green). With | ||
| 173 | approvals required, a =CODEOWNERS= file on the target branch (root or | ||
| 174 | =.gitbay/=) additionally demands an approval from an owner of every | ||
| 175 | owned changed file — gitignore-style patterns, last match wins. | ||
| 176 | |||
| 169 | Review threads anchor to diff lines: | 177 | Review threads anchor to diff lines: |
| 170 | 178 | ||
| 171 | #+begin_src sh | 179 | #+begin_src sh |
e2e/approvals_test.go added +138
| @@ -0,0 +1,138 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestMergeRequirements(t *testing.T) { | ||
| 13 | inst := startInstance(t) | ||
| 14 | aliceKey := inst.newKey(t, "alice") | ||
| 15 | bobKey := inst.newKey(t, "bob") | ||
| 16 | carolKey := inst.newKey(t, "carol") | ||
| 17 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 18 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 19 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 20 | inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub") | ||
| 21 | |||
| 22 | // Repo with CODEOWNERS on main: carol owns *.go. | ||
| 23 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/svc"); code != 0 { | ||
| 24 | t.Fatalf("repo create: %s", errOut) | ||
| 25 | } | ||
| 26 | for _, u := range []string{"bob", "carol"} { | ||
| 27 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/svc", u, "write"); code != 0 { | ||
| 28 | t.Fatal("grant failed") | ||
| 29 | } | ||
| 30 | } | ||
| 31 | work := t.TempDir() | ||
| 32 | env := inst.gitEnv(aliceKey) | ||
| 33 | mustGit(t, work, env, "clone", inst.sshURL("alice/svc"), "w") | ||
| 34 | dir := filepath.Join(work, "w") | ||
| 35 | os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644) | ||
| 36 | os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644) | ||
| 37 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 38 | mustGit(t, dir, env, "add", ".") | ||
| 39 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 40 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 41 | |||
| 42 | // MR by alice touching a .go file. | ||
| 43 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | ||
| 44 | os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n\nvar V = 1\n"), 0o644) | ||
| 45 | mustGit(t, dir, env, "add", ".") | ||
| 46 | mustGit(t, dir, env, "commit", "-q", "-m", "change") | ||
| 47 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | ||
| 48 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc", | ||
| 49 | "--source", "feat", "--target", "main", "--title", "'change'"); code != 0 { | ||
| 50 | t.Fatalf("mr create: %s", errOut) | ||
| 51 | } | ||
| 52 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-approvals", "alice/svc", "1"); code != 0 { | ||
| 53 | t.Fatal("require-approvals failed") | ||
| 54 | } | ||
| 55 | |||
| 56 | // No approvals: refused. The author's own approval does not count. | ||
| 57 | _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | ||
| 58 | if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") { | ||
| 59 | t.Fatalf("no-approval merge: exit %d, %s", code, errOut) | ||
| 60 | } | ||
| 61 | if _, _, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 { | ||
| 62 | t.Fatal("self review failed") | ||
| 63 | } | ||
| 64 | if _, _, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 4 { | ||
| 65 | t.Fatal("author self-approval counted") | ||
| 66 | } | ||
| 67 | |||
| 68 | // Bob approves — but CODEOWNERS demands carol for *.go. | ||
| 69 | if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 { | ||
| 70 | t.Fatal("bob review failed") | ||
| 71 | } | ||
| 72 | _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | ||
| 73 | if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") { | ||
| 74 | t.Fatalf("codeowners gate: exit %d, %s", code, errOut) | ||
| 75 | } | ||
| 76 | |||
| 77 | // A fresh request-changes blocks even with approvals present. | ||
| 78 | if _, _, code = inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--request-changes"); code != 0 { | ||
| 79 | t.Fatal("carol review failed") | ||
| 80 | } | ||
| 81 | _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | ||
| 82 | if code != 4 || !strings.Contains(errOut, "carol requested changes") { | ||
| 83 | t.Fatalf("request-changes block: exit %d, %s", code, errOut) | ||
| 84 | } | ||
| 85 | |||
| 86 | // Carol's latest review wins: her approval satisfies both the count | ||
| 87 | // and CODEOWNERS. | ||
| 88 | if _, _, code = inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 { | ||
| 89 | t.Fatal("carol approve failed") | ||
| 90 | } | ||
| 91 | |||
| 92 | // require-resolved: an open thread still blocks; resolving unblocks. | ||
| 93 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/svc", "on"); code != 0 { | ||
| 94 | t.Fatal("require-resolved failed") | ||
| 95 | } | ||
| 96 | tout, _, code2 := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/svc", "1", | ||
| 97 | "--path", "svc.go", "--line", "3", "--message", "'name it better'", "--json") | ||
| 98 | if code2 != 0 { | ||
| 99 | t.Fatal("diff-comment failed") | ||
| 100 | } | ||
| 101 | var tenv struct { | ||
| 102 | Data struct { | ||
| 103 | Thread int64 `json:"thread"` | ||
| 104 | } `json:"data"` | ||
| 105 | } | ||
| 106 | json.Unmarshal([]byte(tout), &tenv) | ||
| 107 | _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1") | ||
| 108 | if code != 4 || !strings.Contains(errOut, "threads resolved") { | ||
| 109 | t.Fatalf("resolved gate: exit %d, %s", code, errOut) | ||
| 110 | } | ||
| 111 | if _, _, code = inst.ssh(t, bobKey, "", "mr", "resolve", "alice/svc", "1", fmt.Sprint(tenv.Data.Thread)); code != 0 { | ||
| 112 | t.Fatal("resolve failed") | ||
| 113 | } | ||
| 114 | if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { | ||
| 115 | t.Fatalf("fully gated merge: %s", errOut) | ||
| 116 | } | ||
| 117 | |||
| 118 | // Stale approvals never count: new MR, approve, force-push, refused. | ||
| 119 | mustGit(t, dir, env, "fetch", "-q", "origin") | ||
| 120 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat2", "origin/main") | ||
| 121 | os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("n\n"), 0o644) | ||
| 122 | mustGit(t, dir, env, "add", ".") | ||
| 123 | mustGit(t, dir, env, "commit", "-q", "-m", "notes") | ||
| 124 | mustGit(t, dir, env, "push", "-q", "origin", "feat2") | ||
| 125 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc", | ||
| 126 | "--source", "feat2", "--target", "main", "--title", "'notes'"); code != 0 { | ||
| 127 | t.Fatal("mr2 create failed") | ||
| 128 | } | ||
| 129 | if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "2", "--approve"); code != 0 { | ||
| 130 | t.Fatal("bob approve 2 failed") | ||
| 131 | } | ||
| 132 | mustGit(t, dir, env, "commit", "-q", "--amend", "-m", "notes v2") | ||
| 133 | mustGit(t, dir, env, "push", "-q", "--force", "origin", "feat2") | ||
| 134 | _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2") | ||
| 135 | if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") { | ||
| 136 | t.Fatalf("stale approval counted: exit %d, %s", code, errOut) | ||
| 137 | } | ||
| 138 | } | ||
internal/control/mr.go +157
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "errors" | 4 | "errors" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | "slices" | ||
| 7 | "strconv" | 8 | "strconv" |
| 8 | "strings" | 9 | "strings" |
| 9 | 10 | ||
| @@ -16,6 +17,10 @@ import ( | |||
| 16 | func init() { | 17 | func init() { |
| 17 | register(Command{Path: []string{"repo", "fork"}, | 18 | register(Command{Path: []string{"repo", "fork"}, |
| 18 | Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork}) | 19 | Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork}) |
| 20 | register(Command{Path: []string{"repo", "settings", "require-approvals"}, | ||
| 21 | Summary: "require N fresh approvals to merge: repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals}) | ||
| 22 | register(Command{Path: []string{"repo", "settings", "require-resolved"}, | ||
| 23 | Summary: "require all review threads resolved to merge: repo settings require-resolved <owner/name> on|off", Run: runRequireResolved}) | ||
| 19 | register(Command{Path: []string{"repo", "settings", "require-checks"}, | 24 | register(Command{Path: []string{"repo", "settings", "require-checks"}, |
| 20 | Summary: "gate merges on green statuses: repo settings require-checks <owner/name> on|off", Run: runRequireChecks}) | 25 | Summary: "gate merges on green statuses: repo settings require-checks <owner/name> on|off", Run: runRequireChecks}) |
| 21 | register(Command{Path: []string{"repo", "settings", "require-signed"}, | 26 | register(Command{Path: []string{"repo", "settings", "require-signed"}, |
| @@ -99,6 +104,46 @@ func runRepoFork(c *Ctx, args []string) int { | |||
| 99 | }) | 104 | }) |
| 100 | } | 105 | } |
| 101 | 106 | ||
| 107 | func runRequireApprovals(c *Ctx, args []string) int { | ||
| 108 | if len(args) != 2 { | ||
| 109 | return c.fail(protocol.ExitUsage, "usage: repo settings require-approvals <owner/name> <n>") | ||
| 110 | } | ||
| 111 | n, err := strconv.Atoi(args[1]) | ||
| 112 | if err != nil || n < 0 || n > 20 { | ||
| 113 | return c.fail(protocol.ExitUsage, "approvals must be 0..20") | ||
| 114 | } | ||
| 115 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | ||
| 116 | if code >= 0 { | ||
| 117 | return code | ||
| 118 | } | ||
| 119 | s := repo.Settings | ||
| 120 | s.RequireApprovals = n | ||
| 121 | if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { | ||
| 122 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 123 | } | ||
| 124 | return c.emit(s, func(w io.Writer) { | ||
| 125 | fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path()) | ||
| 126 | }) | ||
| 127 | } | ||
| 128 | |||
| 129 | func runRequireResolved(c *Ctx, args []string) int { | ||
| 130 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { | ||
| 131 | return c.fail(protocol.ExitUsage, "usage: repo settings require-resolved <owner/name> on|off") | ||
| 132 | } | ||
| 133 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | ||
| 134 | if code >= 0 { | ||
| 135 | return code | ||
| 136 | } | ||
| 137 | s := repo.Settings | ||
| 138 | s.RequireResolved = args[1] == "on" | ||
| 139 | if err := c.Store.SetRepoSettings(repo.ID, s); err != nil { | ||
| 140 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 141 | } | ||
| 142 | return c.emit(s, func(w io.Writer) { | ||
| 143 | fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path()) | ||
| 144 | }) | ||
| 145 | } | ||
| 146 | |||
| 102 | func runRequireChecks(c *Ctx, args []string) int { | 147 | func runRequireChecks(c *Ctx, args []string) int { |
| 103 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { | 148 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 104 | return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off") | 149 | return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off") |
| @@ -560,6 +605,11 @@ func runMRMerge(c *Ctx, args []string) int { | |||
| 560 | } | 605 | } |
| 561 | } | 606 | } |
| 562 | 607 | ||
| 608 | // Review gates: approvals, CODEOWNERS, resolved threads. | ||
| 609 | if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 { | ||
| 610 | return code | ||
| 611 | } | ||
| 612 | |||
| 563 | upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) | 613 | upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) |
| 564 | if err != nil { | 614 | if err != nil { |
| 565 | return c.fail(protocol.ExitFailure, "%v", err) | 615 | return c.fail(protocol.ExitFailure, "%v", err) |
| @@ -764,6 +814,113 @@ func runMRMerge(c *Ctx, args []string) int { | |||
| 764 | }) | 814 | }) |
| 765 | } | 815 | } |
| 766 | 816 | ||
| 817 | // reviewGates enforces require_approvals (fresh, non-author, latest review | ||
| 818 | // per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and | ||
| 819 | // require_resolved. Returns -1 to proceed. | ||
| 820 | func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { | ||
| 821 | set := repo.Settings | ||
| 822 | if set.RequireApprovals == 0 && !set.RequireResolved { | ||
| 823 | return -1 | ||
| 824 | } | ||
| 825 | |||
| 826 | if set.RequireApprovals > 0 { | ||
| 827 | reviews, err := c.Store.ListMRReviews(mr.ID) | ||
| 828 | if err != nil { | ||
| 829 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 830 | } | ||
| 831 | // Latest fresh review per reviewer decides their stance. | ||
| 832 | latest := map[string]string{} | ||
| 833 | for _, r := range reviews { | ||
| 834 | if r.Stale || r.Reviewer == mr.Author { | ||
| 835 | continue | ||
| 836 | } | ||
| 837 | latest[r.Reviewer] = r.Verdict | ||
| 838 | } | ||
| 839 | var approvers []string | ||
| 840 | var blockers []string | ||
| 841 | for who, verdict := range latest { | ||
| 842 | switch verdict { | ||
| 843 | case "approve": | ||
| 844 | approvers = append(approvers, who) | ||
| 845 | case "request_changes": | ||
| 846 | blockers = append(blockers, who) | ||
| 847 | } | ||
| 848 | } | ||
| 849 | if len(blockers) > 0 { | ||
| 850 | slices.Sort(blockers) | ||
| 851 | return c.fail(protocol.ExitDenied, | ||
| 852 | "%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number) | ||
| 853 | } | ||
| 854 | if len(approvers) < set.RequireApprovals { | ||
| 855 | return c.fail(protocol.ExitDenied, | ||
| 856 | "%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers)) | ||
| 857 | } | ||
| 858 | |||
| 859 | // CODEOWNERS: every owned changed file needs an approval from one | ||
| 860 | // of its owners. | ||
| 861 | content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) | ||
| 862 | if err != nil { | ||
| 863 | content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) | ||
| 864 | } | ||
| 865 | if err == nil && len(content) > 0 { | ||
| 866 | rules := policy.ParseCodeowners(string(content)) | ||
| 867 | base, err := gitutil.MergeBase(dir, targetSHA, headSHA) | ||
| 868 | if err != nil { | ||
| 869 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 870 | } | ||
| 871 | files, err := gitutil.DiffFiles(dir, base, headSHA) | ||
| 872 | if err != nil { | ||
| 873 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 874 | } | ||
| 875 | approved := map[string]bool{} | ||
| 876 | for _, a := range approvers { | ||
| 877 | approved[a] = true | ||
| 878 | } | ||
| 879 | missing := map[string][]string{} // owner-set key -> example paths | ||
| 880 | for _, f := range files { | ||
| 881 | owners := policy.OwnersFor(rules, f) | ||
| 882 | if owners == nil { | ||
| 883 | continue | ||
| 884 | } | ||
| 885 | ok := false | ||
| 886 | for _, o := range owners { | ||
| 887 | if approved[o] { | ||
| 888 | ok = true | ||
| 889 | break | ||
| 890 | } | ||
| 891 | } | ||
| 892 | if !ok { | ||
| 893 | key := strings.Join(owners, ",") | ||
| 894 | if len(missing[key]) < 3 { | ||
| 895 | missing[key] = append(missing[key], f) | ||
| 896 | } | ||
| 897 | } | ||
| 898 | } | ||
| 899 | if len(missing) > 0 { | ||
| 900 | var parts []string | ||
| 901 | for owners, paths := range missing { | ||
| 902 | parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners)) | ||
| 903 | } | ||
| 904 | slices.Sort(parts) | ||
| 905 | return c.fail(protocol.ExitDenied, | ||
| 906 | "CODEOWNERS approval missing for: %s", strings.Join(parts, "; ")) | ||
| 907 | } | ||
| 908 | } | ||
| 909 | } | ||
| 910 | |||
| 911 | if set.RequireResolved { | ||
| 912 | n, err := c.Store.UnresolvedThreadCount(mr.ID) | ||
| 913 | if err != nil { | ||
| 914 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 915 | } | ||
| 916 | if n > 0 { | ||
| 917 | return c.fail(protocol.ExitDenied, | ||
| 918 | "%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number) | ||
| 919 | } | ||
| 920 | } | ||
| 921 | return -1 | ||
| 922 | } | ||
| 923 | |||
| 767 | func runMRClose(c *Ctx, args []string) int { | 924 | func runMRClose(c *Ctx, args []string) int { |
| 768 | repo, mr, code := mrRef(c, args, policy.CanRead) | 925 | repo, mr, code := mrRef(c, args, policy.CanRead) |
| 769 | if code >= 0 { | 926 | if code >= 0 { |
internal/policy/codeowners.go added +79
| @@ -0,0 +1,79 @@ | |||
| 1 | package policy | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "path" | ||
| 5 | "strings" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // CodeownersRule is one line of a CODEOWNERS file: a pattern and the users | ||
| 9 | // who own paths matching it. | ||
| 10 | type CodeownersRule struct { | ||
| 11 | Pattern string | ||
| 12 | Owners []string // usernames, @ stripped | ||
| 13 | } | ||
| 14 | |||
| 15 | // ParseCodeowners reads CODEOWNERS content: one rule per line, gitignore- | ||
| 16 | // style pattern followed by @user owners; #-comments and blanks ignored. | ||
| 17 | func ParseCodeowners(content string) []CodeownersRule { | ||
| 18 | var rules []CodeownersRule | ||
| 19 | for _, line := range strings.Split(content, "\n") { | ||
| 20 | line = strings.TrimSpace(line) | ||
| 21 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 22 | continue | ||
| 23 | } | ||
| 24 | fields := strings.Fields(line) | ||
| 25 | if len(fields) < 2 { | ||
| 26 | continue | ||
| 27 | } | ||
| 28 | var owners []string | ||
| 29 | for _, f := range fields[1:] { | ||
| 30 | owners = append(owners, strings.TrimPrefix(f, "@")) | ||
| 31 | } | ||
| 32 | rules = append(rules, CodeownersRule{Pattern: fields[0], Owners: owners}) | ||
| 33 | } | ||
| 34 | return rules | ||
| 35 | } | ||
| 36 | |||
| 37 | // OwnersFor returns the owners of a path: the last matching rule wins, | ||
| 38 | // CODEOWNERS convention. nil means unowned. | ||
| 39 | func OwnersFor(rules []CodeownersRule, filePath string) []string { | ||
| 40 | var owners []string | ||
| 41 | for _, r := range rules { | ||
| 42 | if codeownersMatch(r.Pattern, filePath) { | ||
| 43 | owners = r.Owners | ||
| 44 | } | ||
| 45 | } | ||
| 46 | return owners | ||
| 47 | } | ||
| 48 | |||
| 49 | // codeownersMatch implements the pattern subset gitbay supports: | ||
| 50 | // - "*" everything | ||
| 51 | // - "*.go" extension match on the basename, anywhere | ||
| 52 | // - "docs/" directory prefix (anywhere unless anchored with /) | ||
| 53 | // - "/cmd/x.go" exact or glob path anchored at the root | ||
| 54 | // - "internal/*" glob against the full path (one segment per *) | ||
| 55 | func codeownersMatch(pattern, filePath string) bool { | ||
| 56 | anchored := strings.HasPrefix(pattern, "/") | ||
| 57 | pattern = strings.TrimPrefix(pattern, "/") | ||
| 58 | if pattern == "*" { | ||
| 59 | return true | ||
| 60 | } | ||
| 61 | // Directory rule: everything under it. | ||
| 62 | if strings.HasSuffix(pattern, "/") { | ||
| 63 | dir := strings.TrimSuffix(pattern, "/") | ||
| 64 | if strings.HasPrefix(filePath, dir+"/") { | ||
| 65 | return true | ||
| 66 | } | ||
| 67 | if !anchored && strings.Contains(filePath, "/"+dir+"/") { | ||
| 68 | return true | ||
| 69 | } | ||
| 70 | return false | ||
| 71 | } | ||
| 72 | // Bare pattern without a slash: match the basename anywhere. | ||
| 73 | if !strings.Contains(pattern, "/") && !anchored { | ||
| 74 | ok, _ := path.Match(pattern, path.Base(filePath)) | ||
| 75 | return ok | ||
| 76 | } | ||
| 77 | ok, _ := path.Match(pattern, filePath) | ||
| 78 | return ok | ||
| 79 | } | ||
internal/policy/codeowners_test.go added +45
| @@ -0,0 +1,45 @@ | |||
| 1 | package policy | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "reflect" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func TestCodeowners(t *testing.T) { | ||
| 9 | rules := ParseCodeowners(` | ||
| 10 | # comment | ||
| 11 | * @alice | ||
| 12 | *.go @bob @carol | ||
| 13 | docs/ @dana | ||
| 14 | /deploy/ @erin | ||
| 15 | /cmd/gitbay/main.go @frank | ||
| 16 | internal/* @grace | ||
| 17 | `) | ||
| 18 | cases := []struct { | ||
| 19 | path string | ||
| 20 | want []string | ||
| 21 | }{ | ||
| 22 | {"README.org", []string{"alice"}}, | ||
| 23 | {"x/y/z.txt", []string{"alice"}}, | ||
| 24 | {"main.go", []string{"bob", "carol"}}, | ||
| 25 | {"deep/nested/thing.go", []string{"bob", "carol"}}, | ||
| 26 | {"docs/users.org", []string{"dana"}}, | ||
| 27 | {"sub/docs/x.md", []string{"dana"}}, // unanchored dir matches anywhere | ||
| 28 | {"deploy/cloud-init.yaml", []string{"erin"}}, // anchored dir | ||
| 29 | {"cmd/gitbay/main.go", []string{"frank"}}, // exact anchored path beats *.go (later rule) | ||
| 30 | {"internal/policy", []string{"grace"}}, // single-segment glob | ||
| 31 | } | ||
| 32 | for _, tc := range cases { | ||
| 33 | if got := OwnersFor(rules, tc.path); !reflect.DeepEqual(got, tc.want) { | ||
| 34 | t.Errorf("OwnersFor(%q) = %v, want %v", tc.path, got, tc.want) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | // Later rules win: a .go file under docs/ belongs to dana, not bob. | ||
| 38 | if got := OwnersFor(rules, "docs/gen.go"); !reflect.DeepEqual(got, []string{"dana"}) { | ||
| 39 | t.Errorf("last-match-wins failed: %v", got) | ||
| 40 | } | ||
| 41 | // Anchored dir does not match nested occurrences. | ||
| 42 | if got := OwnersFor(rules, "x/deploy/f"); !reflect.DeepEqual(got, []string{"alice"}) { | ||
| 43 | t.Errorf("anchored dir leaked: %v", got) | ||
| 44 | } | ||
| 45 | } | ||
internal/store/repos.go +2
| @@ -24,6 +24,8 @@ type RepoSettings struct { | |||
| 24 | ProtectedBranches []string `json:"protected_branches,omitempty"` | 24 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 25 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` | 25 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 26 | RequireChecks bool `json:"require_checks,omitempty"` | 26 | RequireChecks bool `json:"require_checks,omitempty"` |
| 27 | RequireApprovals int `json:"require_approvals,omitempty"` | ||
| 28 | RequireResolved bool `json:"require_resolved,omitempty"` | ||
| 27 | GitDaemon bool `json:"git_daemon,omitempty"` | 29 | GitDaemon bool `json:"git_daemon,omitempty"` |
| 28 | } | 30 | } |
| 29 | 31 | ||