Commit dde9591208
Verified · cmc
cmd/gitbay/main.go +2
| @@ -232,6 +232,8 @@ func repoCmd() *cobra.Command { | ||
| 232 | 232 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 233 | 233 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 234 | 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 | 237 | pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), |
| 236 | 238 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), |
| 237 | 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 | 166 | verified commits merge; everything server-created is refused with |
| 167 | 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 | 177 | Review threads anchor to diff lines: |
| 170 | 178 | |
| 171 | 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 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "slices" | |
| 7 | 8 | "strconv" |
| 8 | 9 | "strings" |
| 9 | 10 | |
| @@ -16,6 +17,10 @@ import ( | ||
| 16 | 17 | func init() { |
| 17 | 18 | register(Command{Path: []string{"repo", "fork"}, |
| 18 | 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 | 24 | register(Command{Path: []string{"repo", "settings", "require-checks"}, |
| 20 | 25 | Summary: "gate merges on green statuses: repo settings require-checks <owner/name> on|off", Run: runRequireChecks}) |
| 21 | 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 | 147 | func runRequireChecks(c *Ctx, args []string) int { |
| 103 | 148 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 104 | 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 | 613 | upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) |
| 564 | 614 | if err != nil { |
| 565 | 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 | 924 | func runMRClose(c *Ctx, args []string) int { |
| 768 | 925 | repo, mr, code := mrRef(c, args, policy.CanRead) |
| 769 | 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 | 24 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 25 | 25 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 26 | 26 | RequireChecks bool `json:"require_checks,omitempty"` |
| 27 | RequireApprovals int `json:"require_approvals,omitempty"` | |
| 28 | RequireResolved bool `json:"require_resolved,omitempty"` | |
| 27 | 29 | GitDaemon bool `json:"git_daemon,omitempty"` |
| 28 | 30 | } |
| 29 | 31 | |