├── .gitignore ├── Makefile ├── go.mod ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .goreleaser.yml ├── go.sum ├── LICENSE ├── match_test.go ├── example_test.go ├── README.md ├── cmd └── codeowners │ └── main.go ├── codeowners.go ├── match.go ├── parse.go ├── testdata └── patterns.json └── parse_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /codeowners 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | go build ./cmd/codeowners 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Unity-Technologies/codeowners 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/spf13/pflag v1.0.5 7 | github.com/stretchr/testify v1.9.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Go 1.x 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ^1.18 24 | 25 | - name: Build cli 26 | run: go build ./cmd/codeowners 27 | 28 | - name: Test 29 | run: go test ./... -v 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 1.18 21 | 22 | - name: Test 23 | run: go test ./... -v 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | version: latest 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | HOMEBREW_TAP_RELEASE_TOKEN: ${{ secrets.HOMEBREW_TAP_RELEASE_TOKEN }} 33 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod download 6 | 7 | builds: 8 | - main: ./cmd/codeowners 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | 18 | brews: 19 | - homepage: "https://github.com/hmarr/codeowners" 20 | description: "Determine who owns what according CODEOWNERS files" 21 | 22 | repository: 23 | owner: hmarr 24 | name: homebrew-tap 25 | token: "{{ .Env.HOMEBREW_TAP_RELEASE_TOKEN }}" 26 | 27 | commit_author: 28 | name: release-bot 29 | email: release-bot@hmarr.com 30 | 31 | directory: Formula 32 | 33 | checksum: 34 | name_template: 'checksums.txt' 35 | 36 | snapshot: 37 | name_template: "{{ .Tag }}-next" 38 | 39 | changelog: 40 | sort: asc 41 | filters: 42 | exclude: 43 | - '^docs:' 44 | - '^test:' 45 | - '^build:' 46 | - '^deps:' 47 | - '(?i)typo' 48 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 6 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 7 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 8 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Harry Marr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /match_test.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type patternTest struct { 13 | Name string `json:"name"` 14 | Pattern string `json:"pattern"` 15 | Paths map[string]bool `json:"paths"` 16 | Focus bool `json:"focus"` 17 | } 18 | 19 | func TestMatch(t *testing.T) { 20 | data, err := os.ReadFile("testdata/patterns.json") 21 | require.NoError(t, err) 22 | 23 | var tests []patternTest 24 | err = json.Unmarshal(data, &tests) 25 | require.NoError(t, err) 26 | 27 | focus := false 28 | for _, test := range tests { 29 | if test.Focus { 30 | focus = true 31 | } 32 | } 33 | 34 | for _, test := range tests { 35 | if test.Focus != focus { 36 | continue 37 | } 38 | 39 | t.Run(test.Name, func(t *testing.T) { 40 | for path, shouldMatch := range test.Paths { 41 | pattern, err := newPattern(test.Pattern) 42 | require.NoError(t, err) 43 | 44 | // Debugging tips: 45 | // - Print the generated regex: `fmt.Println(pattern.regex.String())` 46 | // - Only run a single case by adding `"focus" : true` to the test in the JSON file 47 | 48 | actual, err := pattern.match(path) 49 | require.NoError(t, err) 50 | 51 | if shouldMatch { 52 | assert.True(t, actual, "expected pattern %s to match path %s", test.Pattern, path) 53 | } else { 54 | assert.False(t, actual, "expected pattern %s to not match path %s", test.Pattern, path) 55 | } 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package codeowners_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | 8 | "github.com/Unity-Technologies/codeowners" 9 | ) 10 | 11 | func Example() { 12 | f := bytes.NewBufferString("src/**/*.c @acme/c-developers") 13 | ruleset, err := codeowners.ParseFile(f) 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | match, err := ruleset.Match("src/foo.c") 19 | fmt.Println(match.Owners) 20 | 21 | match, err = ruleset.Match("src/foo.rs") 22 | fmt.Println(match) 23 | // Output: 24 | // [@acme/c-developers] 25 | // 26 | } 27 | 28 | func ExampleParseFile() { 29 | f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code") 30 | ruleset, err := codeowners.ParseFile(f) 31 | if err != nil { 32 | panic(err) 33 | } 34 | fmt.Println(len(ruleset)) 35 | fmt.Println(ruleset[0].RawPattern()) 36 | fmt.Println(ruleset[0].Owners[0].String()) 37 | fmt.Println(ruleset[0].Comment) 38 | // Output: 39 | // 1 40 | // src/**/*.go 41 | // @acme/go-developers 42 | // Go code 43 | } 44 | 45 | func ExampleParseFile_customOwnerMatchers() { 46 | validUsernames := []string{"the-a-team", "the-b-team"} 47 | usernameRegexp := regexp.MustCompile(`\A@([a-zA-Z0-9\-]+)\z`) 48 | 49 | f := bytes.NewBufferString("src/**/*.go @the-a-team # Go code") 50 | ownerMatchers := []codeowners.OwnerMatcher{ 51 | codeowners.OwnerMatchFunc(codeowners.MatchEmailOwner), 52 | codeowners.OwnerMatchFunc(func(s string) (codeowners.Owner, error) { 53 | // Custom owner matcher that only matches valid usernames 54 | match := usernameRegexp.FindStringSubmatch(s) 55 | if match == nil { 56 | return codeowners.Owner{}, codeowners.ErrNoMatch 57 | } 58 | 59 | for _, t := range validUsernames { 60 | if t == match[1] { 61 | return codeowners.Owner{Value: match[1], Type: codeowners.TeamOwner}, nil 62 | } 63 | } 64 | return codeowners.Owner{}, codeowners.ErrNoMatch 65 | }), 66 | } 67 | ruleset, err := codeowners.ParseFile(f, codeowners.WithOwnerMatchers(ownerMatchers)) 68 | if err != nil { 69 | panic(err) 70 | } 71 | fmt.Println(len(ruleset)) 72 | fmt.Println(ruleset[0].RawPattern()) 73 | fmt.Println(ruleset[0].Owners[0].String()) 74 | fmt.Println(ruleset[0].Comment) 75 | // Output: 76 | // 1 77 | // src/**/*.go 78 | // @the-a-team 79 | // Go code 80 | } 81 | 82 | func ExampleRuleset_Match() { 83 | f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code") 84 | ruleset, _ := codeowners.ParseFile(f) 85 | 86 | match, _ := ruleset.Match("src") 87 | fmt.Println("src", match != nil) 88 | 89 | match, _ = ruleset.Match("src/foo.go") 90 | fmt.Println("src/foo.go", match != nil) 91 | 92 | match, _ = ruleset.Match("src/foo/bar.go") 93 | fmt.Println("src/foo/bar.go", match != nil) 94 | 95 | match, _ = ruleset.Match("src/foo.rs") 96 | fmt.Println("src/foo.rs", match != nil) 97 | // Output: 98 | // src false 99 | // src/foo.go true 100 | // src/foo/bar.go true 101 | // src/foo.rs false 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codeowners 2 | 3 | ![build](https://github.com/hmarr/codeowners/workflows/build/badge.svg) 4 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/hmarr/codeowners)](https://pkg.go.dev/github.com/hmarr/codeowners) 5 | 6 | A CLI and Go library for GitHub's [CODEOWNERS file](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax). 7 | 8 | ## Command line tool 9 | 10 | The `codeowners` CLI identifies the owners for files in a local repository or directory. 11 | 12 | ### Installation 13 | 14 | If you're on macOS, you can install the CLI from the [homebrew tap](https://github.com/hmarr/homebrew-tap#codeowners). 15 | 16 | ```console 17 | $ brew tap hmarr/tap 18 | $ brew install codeowners 19 | ``` 20 | 21 | Otherwise, grab a binary from the [releases page](https://github.com/hmarr/codeowners/releases) or install from source with `go install`: 22 | 23 | ```console 24 | $ go install github.com/hmarr/codeowners/cmd/codeowners@latest 25 | ``` 26 | 27 | ### Usage 28 | 29 | By default, the command line tool will walk the directory tree, printing the code owners of any files that are found. 30 | 31 | ```console 32 | $ codeowners --help 33 | usage: codeowners ... 34 | -f, --file string CODEOWNERS file path 35 | -h, --help show this help message 36 | -o, --owner strings filter results by owner 37 | -u, --unowned only show unowned files (can be combined with -o) 38 | 39 | $ ls 40 | CODEOWNERS DOCUMENTATION.md README.md example.go example_test.go 41 | 42 | $ cat CODEOWNERS 43 | *.go @example/go-engineers 44 | *.md @example/docs-writers 45 | README.md product-manager@example.com 46 | 47 | $ codeowners 48 | CODEOWNERS (unowned) 49 | README.md product-manager@example.com 50 | example_test.go @example/go-engineers 51 | example.go @example/go-engineers 52 | DOCUMENTATION.md @example/docs-writers 53 | ``` 54 | 55 | To limit the files the tool looks at, provide one or more paths as arguments. 56 | 57 | ```console 58 | $ codeowners *.md 59 | README.md product-manager@example.com 60 | DOCUMENTATION.md @example/docs-writers 61 | ``` 62 | 63 | Pass the `--owner` flag to filter results by a specific owner. 64 | 65 | ```console 66 | $ codeowners -o @example/go-engineers 67 | example_test.go @example/go-engineers 68 | example.go @example/go-engineers 69 | ``` 70 | 71 | Pass the `--unowned` flag to only show unowned files. 72 | 73 | ```console 74 | $ codeowners -u 75 | CODEOWNERS (unowned) 76 | ``` 77 | 78 | ## Go library 79 | 80 | A package for parsing CODEOWNERS files and matching files to owners. 81 | 82 | ### Installation 83 | 84 | ```console 85 | $ go get github.com/hmarr/codeowners 86 | ``` 87 | 88 | ### Usage 89 | 90 | Full documentation is available at [pkg.go.dev](https://pkg.go.dev/github.com/hmarr/codeowners). 91 | 92 | Here's a quick example to get you started: 93 | 94 | ```go 95 | package main 96 | 97 | import ( 98 | "fmt" 99 | "log" 100 | "os" 101 | 102 | "github.com/hmarr/codeowners" 103 | ) 104 | 105 | func main() { 106 | file, err := os.Open("CODEOWNERS") 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | ruleset, err := codeowners.ParseFile(file) 112 | if err != nil { 113 | log.Fatal(err) 114 | } 115 | 116 | rule, err := ruleset.Match("path/to/file") 117 | if err != nil { 118 | log.Fatal(err) 119 | } 120 | 121 | fmt.Printf("Owners: %v\n", rule.Owners) 122 | } 123 | ``` 124 | -------------------------------------------------------------------------------- /cmd/codeowners/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/Unity-Technologies/codeowners" 12 | flag "github.com/spf13/pflag" 13 | ) 14 | 15 | func main() { 16 | var ( 17 | ownerFilters []string 18 | showUnowned bool 19 | codeownersPath string 20 | helpFlag bool 21 | ) 22 | flag.StringSliceVarP(&ownerFilters, "owner", "o", nil, "filter results by owner") 23 | flag.BoolVarP(&showUnowned, "unowned", "u", false, "only show unowned files (can be combined with -o)") 24 | flag.StringVarP(&codeownersPath, "file", "f", "", "CODEOWNERS file path") 25 | flag.BoolVarP(&helpFlag, "help", "h", false, "show this help message") 26 | 27 | flag.Usage = func() { 28 | fmt.Fprintf(os.Stderr, "usage: codeowners ...\n") 29 | flag.PrintDefaults() 30 | } 31 | flag.Parse() 32 | 33 | if helpFlag { 34 | flag.Usage() 35 | os.Exit(0) 36 | } 37 | 38 | ruleset, err := loadCodeowners(codeownersPath) 39 | if err != nil { 40 | fmt.Fprintln(os.Stderr, err) 41 | os.Exit(1) 42 | } 43 | 44 | paths := flag.Args() 45 | if len(paths) == 0 { 46 | paths = append(paths, ".") 47 | } 48 | 49 | // Make the @ optional for GitHub teams and usernames 50 | for i := range ownerFilters { 51 | ownerFilters[i] = strings.TrimLeft(ownerFilters[i], "@") 52 | } 53 | 54 | out := bufio.NewWriter(os.Stdout) 55 | defer out.Flush() 56 | 57 | for _, startPath := range paths { 58 | // godirwalk only accepts directories, so we need to handle files separately 59 | if !isDir(startPath) { 60 | if err := printFileOwners(out, ruleset, startPath, ownerFilters, showUnowned); err != nil { 61 | fmt.Fprintf(os.Stderr, "error: %v", err) 62 | os.Exit(1) 63 | } 64 | continue 65 | } 66 | 67 | err = filepath.WalkDir(startPath, func(path string, d os.DirEntry, err error) error { 68 | if path == ".git" { 69 | return filepath.SkipDir 70 | } 71 | 72 | // Only show code owners for files, not directories 73 | if !d.IsDir() { 74 | return printFileOwners(out, ruleset, path, ownerFilters, showUnowned) 75 | } 76 | return nil 77 | }) 78 | 79 | if err != nil { 80 | fmt.Fprintf(os.Stderr, "error: %v", err) 81 | os.Exit(1) 82 | } 83 | } 84 | } 85 | 86 | func printFileOwners(out io.Writer, ruleset codeowners.Ruleset, path string, ownerFilters []string, showUnowned bool) error { 87 | rule, err := ruleset.Match(path) 88 | if err != nil { 89 | return err 90 | } 91 | // If we didn't get a match, the file is unowned 92 | if rule == nil || rule.Owners == nil { 93 | // Unless explicitly requested, don't show unowned files if we're filtering by owner 94 | if len(ownerFilters) == 0 || showUnowned { 95 | fmt.Fprintf(out, "%-70s (unowned)\n", path) 96 | } 97 | return nil 98 | } 99 | 100 | // Figure out which of the owners we need to show according to the --owner filters 101 | ownersToShow := make([]string, 0, len(rule.Owners)) 102 | for _, o := range rule.Owners { 103 | // If there are no filters, show all owners 104 | filterMatch := len(ownerFilters) == 0 && !showUnowned 105 | for _, filter := range ownerFilters { 106 | if filter == o.Value { 107 | filterMatch = true 108 | } 109 | } 110 | if filterMatch { 111 | ownersToShow = append(ownersToShow, o.String()) 112 | } 113 | } 114 | 115 | // If the owners slice is empty, no owners matched the filters so don't show anything 116 | if len(ownersToShow) > 0 { 117 | fmt.Fprintf(out, "%-70s %s\n", path, strings.Join(ownersToShow, " ")) 118 | } 119 | return nil 120 | } 121 | 122 | func loadCodeowners(path string) (codeowners.Ruleset, error) { 123 | if path == "" { 124 | return codeowners.LoadFileFromStandardLocation() 125 | } 126 | return codeowners.LoadFile(path) 127 | } 128 | 129 | // isDir checks if there's a directory at the path specified. 130 | func isDir(path string) bool { 131 | info, err := os.Stat(path) 132 | if os.IsNotExist(err) { 133 | return false 134 | } 135 | return info.IsDir() 136 | } 137 | -------------------------------------------------------------------------------- /codeowners.go: -------------------------------------------------------------------------------- 1 | // Package codeowners is a library for working with CODEOWNERS files. 2 | // 3 | // CODEOWNERS files map gitignore-style path patterns to sets of owners, which 4 | // may be GitHub users, GitHub teams, or email addresses. This library parses 5 | // the CODEOWNERS file format into rulesets, which may then be used to determine 6 | // the ownership of files. 7 | // 8 | // Usage 9 | // 10 | // To find the owner of a given file, parse a CODEOWNERS file and call Match() 11 | // on the resulting ruleset. 12 | // ruleset, err := codeowners.ParseFile(file) 13 | // if err != nil { 14 | // log.Fatal(err) 15 | // } 16 | // 17 | // rule, err := ruleset.Match("path/to/file") 18 | // if err != nil { 19 | // log.Fatal(err) 20 | // } 21 | // 22 | // Command line interface 23 | // 24 | // A command line interface is also available in the cmd/codeowners package. 25 | // When run, it will walk the directory tree showing the code owners for each 26 | // file encountered. The help flag lists available options. 27 | // 28 | // $ codeowners --help 29 | package codeowners 30 | 31 | import ( 32 | "fmt" 33 | "os" 34 | "os/exec" 35 | "path/filepath" 36 | "strings" 37 | ) 38 | 39 | // LoadFileFromStandardLocation loads and parses a CODEOWNERS file at one of the 40 | // standard locations for CODEOWNERS files (./, .github/, docs/). If run from a 41 | // git repository, all paths are relative to the repository root. 42 | func LoadFileFromStandardLocation() (Ruleset, error) { 43 | path := findFileAtStandardLocation() 44 | if path == "" { 45 | return nil, fmt.Errorf("could not find CODEOWNERS file at any of the standard locations") 46 | } 47 | return LoadFile(path) 48 | } 49 | 50 | // LoadFile loads and parses a CODEOWNERS file at the path specified. 51 | func LoadFile(path string) (Ruleset, error) { 52 | f, err := os.Open(path) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return ParseFile(f) 57 | } 58 | 59 | // findFileAtStandardLocation loops through the standard locations for 60 | // CODEOWNERS files (./, .github/, docs/), and returns the first place a 61 | // CODEOWNERS file is found. If run from a git repository, all paths are 62 | // relative to the repository root. 63 | func findFileAtStandardLocation() string { 64 | pathPrefix := "" 65 | repoRoot, inRepo := findRepositoryRoot() 66 | if inRepo { 67 | pathPrefix = repoRoot 68 | } 69 | 70 | for _, path := range []string{"CODEOWNERS", ".github/CODEOWNERS", ".gitlab/CODEOWNERS", "docs/CODEOWNERS"} { 71 | fullPath := filepath.Join(pathPrefix, path) 72 | if fileExists(fullPath) { 73 | return fullPath 74 | } 75 | } 76 | return "" 77 | } 78 | 79 | // fileExist checks if a normal file exists at the path specified. 80 | func fileExists(path string) bool { 81 | info, err := os.Stat(path) 82 | if os.IsNotExist(err) { 83 | return false 84 | } 85 | return !info.IsDir() 86 | } 87 | 88 | // findRepositoryRoot returns the path to the root of the git repository, if 89 | // we're currently in one. If we're not in a git repository, the boolean return 90 | // value is false. 91 | func findRepositoryRoot() (string, bool) { 92 | output, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() 93 | if err != nil { 94 | return "", false 95 | } 96 | return strings.TrimSpace(string(output)), true 97 | } 98 | 99 | // Ruleset is a collection of CODEOWNERS rules. 100 | type Ruleset []Rule 101 | 102 | // Match finds the last rule in the ruleset that matches the path provided. When 103 | // determining the ownership of a file using CODEOWNERS, order matters, and the 104 | // last matching rule takes precedence. 105 | func (r Ruleset) Match(path string) (*Rule, error) { 106 | for i := len(r) - 1; i >= 0; i-- { 107 | rule := &r[i] 108 | match, err := rule.Match(path) 109 | if match || err != nil { 110 | return rule, err 111 | } 112 | } 113 | return nil, nil 114 | } 115 | 116 | // Rule is a CODEOWNERS rule that maps a gitignore-style path pattern to a set 117 | // of owners. 118 | type Rule struct { 119 | Owners []Owner 120 | Comment string 121 | LineNumber int 122 | pattern pattern 123 | } 124 | 125 | // RawPattern returns the rule's gitignore-style path pattern. 126 | func (r Rule) RawPattern() string { 127 | return r.pattern.pattern 128 | } 129 | 130 | // Match tests whether the provided matches the rule's pattern. 131 | func (r Rule) Match(path string) (bool, error) { 132 | return r.pattern.match(path) 133 | } 134 | 135 | const ( 136 | // EmailOwner is the owner type for email addresses. 137 | EmailOwner string = "email" 138 | // TeamOwner is the owner type for GitHub teams. 139 | TeamOwner string = "team" 140 | // UsernameOwner is the owner type for GitHub usernames. 141 | UsernameOwner string = "username" 142 | ) 143 | 144 | // Owner represents an owner found in a rule. 145 | type Owner struct { 146 | // Value is the name of the owner: the email addres, team name, or username. 147 | Value string 148 | // Type will be one of 'email', 'team', or 'username'. 149 | Type string 150 | } 151 | 152 | // String returns a string representation of the owner. For email owners, it 153 | // simply returns the email address. For user and team owners it prepends an '@' 154 | // to the owner. 155 | func (o Owner) String() string { 156 | if o.Type == EmailOwner { 157 | return o.Value 158 | } 159 | return "@" + o.Value 160 | } 161 | -------------------------------------------------------------------------------- /match.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | type pattern struct { 11 | pattern string 12 | regex *regexp.Regexp 13 | leftAnchoredLiteral bool 14 | } 15 | 16 | // newPattern creates a new pattern struct from a gitignore-style pattern string 17 | func newPattern(patternStr string) (pattern, error) { 18 | pat := pattern{pattern: patternStr} 19 | 20 | if !strings.ContainsAny(patternStr, "*?\\") && patternStr[0] == '/' { 21 | pat.leftAnchoredLiteral = true 22 | } else { 23 | patternRegex, err := buildPatternRegex(patternStr) 24 | if err != nil { 25 | return pattern{}, err 26 | } 27 | pat.regex = patternRegex 28 | } 29 | 30 | return pat, nil 31 | } 32 | 33 | // match tests if the path provided matches the pattern 34 | func (p pattern) match(testPath string) (bool, error) { 35 | // Normalize Windows-style path separators to forward slashes 36 | testPath = filepath.ToSlash(testPath) 37 | 38 | if p.leftAnchoredLiteral { 39 | prefix := p.pattern 40 | 41 | // Strip the leading slash as we're anchored to the root already 42 | if prefix[0] == '/' { 43 | prefix = prefix[1:] 44 | } 45 | 46 | // If the pattern ends with a slash we can do a simple prefix match 47 | if prefix[len(prefix)-1] == '/' { 48 | return strings.HasPrefix(testPath, prefix), nil 49 | } 50 | 51 | // If the strings are the same length, check for an exact match 52 | if len(testPath) == len(prefix) { 53 | return testPath == prefix, nil 54 | } 55 | 56 | // Otherwise check if the test path is a subdirectory of the pattern 57 | if len(testPath) > len(prefix) && testPath[len(prefix)] == '/' { 58 | return testPath[:len(prefix)] == prefix, nil 59 | } 60 | 61 | // Otherwise the test path must be shorter than the pattern, so it can't match 62 | return false, nil 63 | } 64 | 65 | return p.regex.MatchString(testPath), nil 66 | } 67 | 68 | // buildPatternRegex compiles a new regexp object from a gitignore-style pattern string 69 | func buildPatternRegex(pattern string) (*regexp.Regexp, error) { 70 | // Handle specific edge cases first 71 | switch { 72 | case strings.Contains(pattern, "***"): 73 | return nil, fmt.Errorf("pattern cannot contain three consecutive asterisks") 74 | case pattern == "": 75 | return nil, fmt.Errorf("empty pattern") 76 | case pattern == "/": 77 | // "/" doesn't match anything 78 | return regexp.Compile(`\A\z`) 79 | } 80 | 81 | segs := strings.Split(pattern, "/") 82 | 83 | if segs[0] == "" { 84 | // Leading slash: match is relative to root 85 | segs = segs[1:] 86 | } else { 87 | // No leading slash - check for a single segment pattern, which matches 88 | // relative to any descendent path (equivalent to a leading **/) 89 | if len(segs) == 1 || (len(segs) == 2 && segs[1] == "") { 90 | if segs[0] != "**" { 91 | segs = append([]string{"**"}, segs...) 92 | } 93 | } 94 | } 95 | 96 | if len(segs) > 1 && segs[len(segs)-1] == "" { 97 | // Trailing slash is equivalent to "/**" 98 | segs[len(segs)-1] = "**" 99 | } 100 | 101 | sep := "/" 102 | 103 | lastSegIndex := len(segs) - 1 104 | needSlash := false 105 | var re strings.Builder 106 | re.WriteString(`\A`) 107 | for i, seg := range segs { 108 | switch seg { 109 | case "**": 110 | switch { 111 | case i == 0 && i == lastSegIndex: 112 | // If the pattern is just "**" we match everything 113 | re.WriteString(`.+`) 114 | case i == 0: 115 | // If the pattern starts with "**" we match any leading path segment 116 | re.WriteString(`(?:.+` + sep + `)?`) 117 | needSlash = false 118 | case i == lastSegIndex: 119 | // If the pattern ends with "**" we match any trailing path segment 120 | re.WriteString(sep + `.*`) 121 | default: 122 | // If the pattern contains "**" we match zero or more path segments 123 | re.WriteString(`(?:` + sep + `.+)?`) 124 | needSlash = true 125 | } 126 | 127 | case "*": 128 | if needSlash { 129 | re.WriteString(sep) 130 | } 131 | 132 | // Regular wildcard - match any characters except the separator 133 | re.WriteString(`[^` + sep + `]+`) 134 | needSlash = true 135 | 136 | default: 137 | if needSlash { 138 | re.WriteString(sep) 139 | } 140 | 141 | escape := false 142 | for _, ch := range seg { 143 | if escape { 144 | escape = false 145 | re.WriteString(regexp.QuoteMeta(string(ch))) 146 | continue 147 | } 148 | 149 | // Other pathspec implementations handle character classes here (e.g. 150 | // [AaBb]), but CODEOWNERS doesn't support that so we don't need to 151 | switch ch { 152 | case '\\': 153 | escape = true 154 | case '*': 155 | // Multi-character wildcard 156 | re.WriteString(`[^` + sep + `]*`) 157 | case '?': 158 | // Single-character wildcard 159 | re.WriteString(`[^` + sep + `]`) 160 | default: 161 | // Regular character 162 | re.WriteString(regexp.QuoteMeta(string(ch))) 163 | } 164 | } 165 | 166 | if i == lastSegIndex { 167 | // As there's no trailing slash (that'd hit the '**' case), we 168 | // need to match descendent paths 169 | re.WriteString(`(?:` + sep + `.*)?`) 170 | } 171 | 172 | needSlash = true 173 | } 174 | } 175 | re.WriteString(`\z`) 176 | return regexp.Compile(re.String()) 177 | } 178 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | type parseOption func(*parseOptions) 14 | 15 | type parseOptions struct { 16 | ownerMatchers []OwnerMatcher 17 | } 18 | 19 | func WithOwnerMatchers(mm []OwnerMatcher) parseOption { 20 | return func(opts *parseOptions) { 21 | opts.ownerMatchers = mm 22 | } 23 | } 24 | 25 | type OwnerMatcher interface { 26 | // Matches give string agains a pattern e.g. a regexp. 27 | // Should return ErrNoMatch if the pattern doesn't match. 28 | Match(s string) (Owner, error) 29 | } 30 | 31 | type ErrInvalidOwnerFormat struct { 32 | Owner string 33 | } 34 | 35 | func (err ErrInvalidOwnerFormat) Error() string { 36 | return fmt.Sprintf("invalid owner format '%s'", err.Owner) 37 | } 38 | 39 | var ErrNoMatch = errors.New("no match") 40 | 41 | var ( 42 | emailRegexp = regexp.MustCompile(`\A[A-Z0-9a-z\._%\+\-]+@[A-Za-z0-9\.\-]+\.[A-Za-z]{2,6}\z`) 43 | teamRegexp = regexp.MustCompile(`\A@([a-zA-Z0-9\-]+\/[a-zA-Z0-9_\-]+)\z`) 44 | usernameRegexp = regexp.MustCompile(`\A@([a-zA-Z0-9\-_]+)\z`) 45 | ) 46 | 47 | // DefaultOwnerMatchers is the default set of owner matchers, which includes the 48 | // GitHub-flavored email, team, and username matchers. 49 | var DefaultOwnerMatchers = []OwnerMatcher{ 50 | OwnerMatchFunc(MatchEmailOwner), 51 | OwnerMatchFunc(MatchTeamOwner), 52 | OwnerMatchFunc(MatchUsernameOwner), 53 | } 54 | 55 | // OwnerMatchFunc is a function that matches a string against a pattern and 56 | // returns an Owner, or ErrNoMatch if no match was found. It implements the 57 | // OwnerMatcher interface and may be provided to WithOwnerMatchers to customize 58 | // owner matching behavior (e.g. to support GitLab-style team names). 59 | type OwnerMatchFunc func(s string) (Owner, error) 60 | 61 | func (f OwnerMatchFunc) Match(s string) (Owner, error) { 62 | return f(s) 63 | } 64 | 65 | // MatchEmailOwner matches an email address owner. May be provided to 66 | // WithOwnerMatchers. 67 | func MatchEmailOwner(s string) (Owner, error) { 68 | match := emailRegexp.FindStringSubmatch(s) 69 | if match == nil { 70 | return Owner{}, ErrNoMatch 71 | } 72 | 73 | return Owner{Value: match[0], Type: EmailOwner}, nil 74 | } 75 | 76 | // MatchTeamOwner matches a GitHub team owner. May be provided to 77 | // WithOwnerMatchers. 78 | func MatchTeamOwner(s string) (Owner, error) { 79 | match := teamRegexp.FindStringSubmatch(s) 80 | if match == nil { 81 | return Owner{}, ErrNoMatch 82 | } 83 | 84 | return Owner{Value: match[1], Type: TeamOwner}, nil 85 | } 86 | 87 | // MatchUsernameOwner matches a GitHub username owner. May be provided to 88 | // WithOwnerMatchers. 89 | func MatchUsernameOwner(s string) (Owner, error) { 90 | match := usernameRegexp.FindStringSubmatch(s) 91 | if match == nil { 92 | return Owner{}, ErrNoMatch 93 | } 94 | 95 | return Owner{Value: match[1], Type: UsernameOwner}, nil 96 | } 97 | 98 | // ParseFile parses a CODEOWNERS file, returning a set of rules. 99 | // To override the default owner matchers, pass WithOwnerMatchers() as an option. 100 | func ParseFile(f io.Reader, options ...parseOption) (Ruleset, error) { 101 | opts := parseOptions{ownerMatchers: DefaultOwnerMatchers} 102 | for _, opt := range options { 103 | opt(&opts) 104 | } 105 | 106 | rules := Ruleset{} 107 | scanner := bufio.NewScanner(f) 108 | lineNo := 0 109 | for scanner.Scan() { 110 | lineNo++ 111 | line := strings.TrimSpace(scanner.Text()) 112 | 113 | // Ignore blank lines and comments 114 | if len(line) == 0 || line[0] == '#' { 115 | continue 116 | } 117 | 118 | rule, err := parseRule(line, opts) 119 | if err != nil { 120 | return nil, fmt.Errorf("line %d: %w", lineNo, err) 121 | } 122 | rule.LineNumber = lineNo 123 | rules = append(rules, rule) 124 | } 125 | return rules, nil 126 | } 127 | 128 | const ( 129 | statePattern = iota + 1 130 | stateOwners 131 | ) 132 | 133 | // parseRule parses a single line of a CODEOWNERS file, returning a Rule struct 134 | func parseRule(ruleStr string, opts parseOptions) (Rule, error) { 135 | r := Rule{} 136 | 137 | state := statePattern 138 | escaped := false 139 | buf := bytes.Buffer{} 140 | for i, ch := range strings.TrimSpace(ruleStr) { 141 | // Comments consume the rest of the line and stop further parsing 142 | if ch == '#' { 143 | r.Comment = strings.TrimSpace(ruleStr[i+1:]) 144 | break 145 | } 146 | 147 | switch state { 148 | case statePattern: 149 | switch { 150 | case ch == '\\': 151 | // Escape the next character (important for whitespace while parsing), but 152 | // don't lose the backslash as it's part of the pattern 153 | escaped = true 154 | buf.WriteRune(ch) 155 | continue 156 | 157 | case isWhitespace(ch) && !escaped: 158 | // Unescaped whitespace means this is the end of the pattern 159 | pattern, err := newPattern(buf.String()) 160 | if err != nil { 161 | return r, err 162 | } 163 | r.pattern = pattern 164 | buf.Reset() 165 | state = stateOwners 166 | 167 | case isPatternChar(ch) || (isWhitespace(ch) && escaped): 168 | // Keep any valid pattern characters and escaped whitespace 169 | buf.WriteRune(ch) 170 | 171 | default: 172 | return r, fmt.Errorf("unexpected character '%c' at position %d", ch, i+1) 173 | } 174 | // Escaping only applies to one character 175 | escaped = false 176 | 177 | case stateOwners: 178 | switch { 179 | case isWhitespace(ch): 180 | // Whitespace means we've reached the end of the owner or we're just chomping 181 | // through whitespace before or after owner declarations 182 | if buf.Len() > 0 { 183 | ownerStr := buf.String() 184 | owner, err := newOwner(ownerStr, opts.ownerMatchers) 185 | if err != nil { 186 | return r, fmt.Errorf("%w at position %d", err, i+1-len(ownerStr)) 187 | } 188 | r.Owners = append(r.Owners, owner) 189 | buf.Reset() 190 | } 191 | 192 | case isOwnersChar(ch): 193 | // Write valid owner characters to the buffer 194 | buf.WriteRune(ch) 195 | 196 | default: 197 | return r, fmt.Errorf("unexpected character '%c' at position %d", ch, i+1) 198 | } 199 | } 200 | } 201 | 202 | // We've finished consuming the line, but we might still have content in the buffer 203 | // if the line didn't end with a separator (whitespace) 204 | switch state { 205 | case statePattern: 206 | if buf.Len() == 0 { // We should have non-empty pattern 207 | return r, fmt.Errorf("unexpected end of rule") 208 | } 209 | 210 | pattern, err := newPattern(buf.String()) 211 | if err != nil { 212 | return r, err 213 | } 214 | r.pattern = pattern 215 | 216 | case stateOwners: 217 | // If there's an owner left in the buffer, don't leave it behind 218 | if buf.Len() > 0 { 219 | ownerStr := buf.String() 220 | owner, err := newOwner(ownerStr, opts.ownerMatchers) 221 | if err != nil { 222 | return r, fmt.Errorf("%s at position %d", err.Error(), len(ruleStr)+1-len(ownerStr)) 223 | } 224 | r.Owners = append(r.Owners, owner) 225 | } 226 | } 227 | 228 | return r, nil 229 | } 230 | 231 | // newOwner figures out which kind of owner this is and returns an Owner struct 232 | func newOwner(s string, mm []OwnerMatcher) (Owner, error) { 233 | for _, m := range mm { 234 | o, err := m.Match(s) 235 | if errors.Is(err, ErrNoMatch) { 236 | continue 237 | } else if err != nil { 238 | return Owner{}, err 239 | } 240 | 241 | return o, nil 242 | } 243 | 244 | return Owner{}, ErrInvalidOwnerFormat{ 245 | Owner: s, 246 | } 247 | } 248 | 249 | func isWhitespace(ch rune) bool { 250 | return ch == ' ' || ch == '\t' || ch == '\n' 251 | } 252 | 253 | func isAlphanumeric(ch rune) bool { 254 | return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') 255 | } 256 | 257 | // isPatternChar matches characters that are allowed in patterns 258 | func isPatternChar(ch rune) bool { 259 | switch ch { 260 | case '*', '?', '.', '/', '@', '_', '+', '-', '\\', '(', ')', '|', '{', '}', '~': 261 | return true 262 | } 263 | return isAlphanumeric(ch) 264 | } 265 | 266 | // isOwnersChar matches characters that are allowed in owner definitions 267 | func isOwnersChar(ch rune) bool { 268 | switch ch { 269 | case '.', '@', '/', '_', '%', '+', '-': 270 | return true 271 | } 272 | return isAlphanumeric(ch) 273 | } 274 | -------------------------------------------------------------------------------- /testdata/patterns.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "single-segment pattern", 4 | "pattern": "foo", 5 | "paths": { 6 | "foo": true, 7 | "foo.txt": false, 8 | "foo/bar": true, 9 | "bar/foo": true, 10 | "bar/foo.txt": false, 11 | "bar/baz": false, 12 | "bar/foo/baz": true 13 | } 14 | }, 15 | { 16 | "name": "single-segment pattern with leading slash", 17 | "pattern": "/foo", 18 | "paths": { 19 | "foo": true, 20 | "fool.txt": false, 21 | "foo/bar": true, 22 | "bar/foo": false, 23 | "bar/baz": false, 24 | "foo/bar/baz": true, 25 | "bar/foo/baz": false 26 | } 27 | }, 28 | { 29 | "name": "single-segment pattern with trailing slash", 30 | "pattern": "foo/", 31 | "paths": { 32 | "foo": false, 33 | "foo/bar": true, 34 | "foo/bar/baz": true, 35 | "bar/foo": false, 36 | "bar/baz": false, 37 | "bar/foo/baz": true, 38 | "bar/foo/baz/qux": true 39 | } 40 | }, 41 | { 42 | "name": "single-segment pattern with leading and trailing slash", 43 | "pattern": "/foo/", 44 | "paths": { 45 | "foo": false, 46 | "foo/bar": true, 47 | "foo/bar/baz": true, 48 | "bar/foo": false, 49 | "bar/baz": false, 50 | "bar/foo/baz": false, 51 | "bar/foo/baz/qux": false 52 | } 53 | }, 54 | { 55 | "name": "multi-segment (implicitly left-anchored) pattern", 56 | "pattern": "foo/bar", 57 | "paths": { 58 | "foo/bar": true, 59 | "foo/bart": false, 60 | "foo/bar/baz": true, 61 | "baz/foo/bar": false, 62 | "baz/foo/bar/qux": false 63 | } 64 | }, 65 | { 66 | "name": "multi-segment pattern with leading slash", 67 | "pattern": "/foo/bar", 68 | "paths": { 69 | "foo/bar": true, 70 | "foo/bart": false, 71 | "foo/bar/baz": true, 72 | "baz/foo/bar": false, 73 | "baz/foo/bar/qux": false 74 | } 75 | }, 76 | { 77 | "name": "multi-segment pattern with trailing slash", 78 | "pattern": "foo/bar/", 79 | "paths": { 80 | "foo/bar": false, 81 | "foo/bart": false, 82 | "foo/bar/baz": true, 83 | "baz/foo/bar": false, 84 | "baz/foo/bar/qux": false 85 | } 86 | }, 87 | { 88 | "name": "multi-segment pattern with leading and trailing slash", 89 | "pattern": "/foo/bar/", 90 | "paths": { 91 | "foo/bar": false, 92 | "foo/bart": false, 93 | "foo/bar/baz": true, 94 | "foo/bar/baz/qux": true, 95 | "baz/foo/bar": false, 96 | "baz/foo/bar/qux": false 97 | } 98 | }, 99 | { 100 | "name": "single segment lone wildcard", 101 | "pattern": "*", 102 | "paths": { 103 | "foo": true, 104 | "foo/bar": true, 105 | "bar/foo": true, 106 | "bar/foo/baz": true, 107 | "bar/baz": true, 108 | "xfoo": true 109 | } 110 | }, 111 | { 112 | "name": "single segment pattern with wildcard", 113 | "pattern": "f*", 114 | "paths": { 115 | "foo": true, 116 | "foo/bar": true, 117 | "foo/bar/baz": true, 118 | "bar/foo": true, 119 | "bar/foo/baz": true, 120 | "bar/baz": false, 121 | "xfoo": false 122 | } 123 | }, 124 | { 125 | "name": "single segment pattern with leading slash and lone wildcard", 126 | "pattern": "/*", 127 | "paths": { 128 | "foo": true, 129 | "bar": true, 130 | "foo/bar": false, 131 | "foo/bar/baz": false 132 | } 133 | }, 134 | { 135 | "name": "single segment pattern with leading slash and wildcard", 136 | "pattern": "/f*", 137 | "paths": { 138 | "foo": true, 139 | "foo/bar": true, 140 | "foo/bar/baz": true, 141 | "bar/foo": false, 142 | "bar/foo/baz": false, 143 | "bar/baz": false, 144 | "xfoo": false 145 | } 146 | }, 147 | { 148 | "name": "single segment pattern with trailing slash and wildcard", 149 | "pattern": "f*/", 150 | "paths": { 151 | "foo": false, 152 | "foo/bar": true, 153 | "bar/foo": false, 154 | "bar/foo/baz": true, 155 | "bar/baz": false, 156 | "xfoo": false 157 | } 158 | }, 159 | { 160 | "name": "single segment pattern with leading and trailing slash and lone wildcard", 161 | "pattern": "/*/", 162 | "paths": { 163 | "foo": false, 164 | "foo/bar": true, 165 | "bar/foo": true, 166 | "bar/foo/baz": true 167 | } 168 | }, 169 | { 170 | "name": "single segment pattern with leading and trailing slash and wildcard", 171 | "pattern": "/f*/", 172 | "paths": { 173 | "foo": false, 174 | "foo/bar": true, 175 | "bar/foo": false, 176 | "bar/foo/baz": false, 177 | "bar/baz": false, 178 | "xfoo": false 179 | } 180 | }, 181 | { 182 | "name": "single segment pattern with escaped wildcard", 183 | "pattern": "f\\*o", 184 | "paths": { 185 | "foo": false, 186 | "f*o": true 187 | } 188 | }, 189 | { 190 | "name": "pattern with trailing wildcard segment", 191 | "pattern": "foo/*", 192 | "paths": { 193 | "foo": false, 194 | "foo/bar": true, 195 | "foo/bar/baz": false, 196 | "bar/foo": false, 197 | "bar/foo/baz": false, 198 | "bar/baz": false, 199 | "xfoo": false 200 | } 201 | }, 202 | { 203 | "name": "multi-segment pattern with wildcard", 204 | "pattern": "foo/*.txt", 205 | "paths": { 206 | "foo": false, 207 | "foo/bar.txt": true, 208 | "foo/bar/baz.txt": false, 209 | "qux/foo/bar.txt": false, 210 | "qux/foo/bar/baz.txt": false 211 | } 212 | }, 213 | { 214 | "name": "multi-segment pattern with lone wildcard", 215 | "pattern": "foo/*/baz", 216 | "paths": { 217 | "foo": false, 218 | "foo/bar": false, 219 | "foo/baz": false, 220 | "foo/bar/baz": true, 221 | "foo/bar/baz/qux": true 222 | } 223 | }, 224 | { 225 | "name": "single segment pattern with single-character wildcard", 226 | "pattern": "f?o", 227 | "paths": { 228 | "foo": true, 229 | "fo": false, 230 | "fooo": false 231 | } 232 | }, 233 | { 234 | "name": "single segment pattern with escaped single-character wildcard", 235 | "pattern": "f\\?o", 236 | "paths": { 237 | "foo": false, 238 | "f?o": true 239 | } 240 | }, 241 | { 242 | "name": "leading double-asterisk wildcard", 243 | "pattern": "**/foo/bar", 244 | "paths": { 245 | "foo/bar": true, 246 | "qux/foo/bar": true, 247 | "qux/foo/bar/baz": true, 248 | "foo/baz/bar": false, 249 | "qux/foo/baz/bar": false 250 | } 251 | }, 252 | { 253 | "name": "leading double-asterisk wildcard with regular wildcard", 254 | "pattern": "**/*bar*", 255 | "paths": { 256 | "bar": true, 257 | "foo/bar": true, 258 | "foo/rebar": true, 259 | "foo/barrio": true, 260 | "foo/qux/bar": true 261 | } 262 | }, 263 | { 264 | "name": "trailing double-asterisk wildcard", 265 | "pattern": "foo/bar/**", 266 | "paths": { 267 | "foo/bar": false, 268 | "foo/bar/baz": true, 269 | "foo/bar/baz/qux": true, 270 | "qux/foo/bar": false, 271 | "qux/foo/bar/baz": false 272 | } 273 | }, 274 | { 275 | "name": "middle double-asterisk wildcard", 276 | "pattern": "foo/**/bar", 277 | "paths": { 278 | "foo/bar": true, 279 | "foo/bar/baz": true, 280 | "foo/qux/bar/baz": true, 281 | "foo/qux/quux/bar/baz": true, 282 | "foo/bar/baz/qux": true, 283 | "qux/foo/bar": false, 284 | "qux/foo/bar/baz": false 285 | } 286 | }, 287 | { 288 | "name": "middle double-asterisk wildcard with trailing slash", 289 | "pattern": "foo/**/", 290 | "paths": { 291 | "foo": false, 292 | "foo/bar": true, 293 | "foo/bar/": true, 294 | "foo/bar/baz": true 295 | } 296 | }, 297 | { 298 | "name": "middle double-asterisk wildcard with trailing wildcard", 299 | "pattern": "foo/**/bar/b*", 300 | "paths": { 301 | "foo/bar": false, 302 | "foo/bar/baz": true, 303 | "foo/bar/qux": false, 304 | "foo/qux/bar": false, 305 | "foo/qux/bar/baz": true, 306 | "foo/qux/bar/qux": false 307 | } 308 | } 309 | ] -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseFile(t *testing.T) { 11 | examples := []struct { 12 | name string 13 | contents string 14 | expected Ruleset 15 | err string 16 | }{ 17 | // Success cases 18 | { 19 | name: "empty file", 20 | contents: "", 21 | expected: Ruleset{}, 22 | }, 23 | { 24 | name: "single rule", 25 | contents: "file.txt @user", 26 | expected: Ruleset{ 27 | { 28 | pattern: mustBuildPattern(t, "file.txt"), 29 | Owners: []Owner{{Value: "user", Type: "username"}}, 30 | LineNumber: 1, 31 | }, 32 | }, 33 | }, 34 | { 35 | name: "multiple rules", 36 | contents: "file.txt @user\nfile2.txt @org/team", 37 | expected: Ruleset{ 38 | { 39 | pattern: mustBuildPattern(t, "file.txt"), 40 | Owners: []Owner{{Value: "user", Type: "username"}}, 41 | LineNumber: 1, 42 | }, 43 | { 44 | pattern: mustBuildPattern(t, "file2.txt"), 45 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 46 | LineNumber: 2, 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "with blank lines with whitespace", 52 | contents: "\nfile.txt @user\n \t\nfile2.txt @org/team\n", 53 | expected: Ruleset{ 54 | { 55 | pattern: mustBuildPattern(t, "file.txt"), 56 | Owners: []Owner{{Value: "user", Type: "username"}}, 57 | LineNumber: 2, 58 | }, 59 | { 60 | pattern: mustBuildPattern(t, "file2.txt"), 61 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 62 | LineNumber: 4, 63 | }, 64 | }, 65 | }, 66 | 67 | // Error cases 68 | { 69 | name: "malformed rule", 70 | contents: "malformed rule\n", 71 | err: "line 1: invalid owner format 'rule' at position 11", 72 | }, 73 | } 74 | 75 | for _, e := range examples { 76 | t.Run("parses "+e.name, func(t *testing.T) { 77 | reader := strings.NewReader(e.contents) 78 | actual, err := ParseFile(reader) 79 | if e.err != "" { 80 | assert.EqualError(t, err, e.err) 81 | } else { 82 | assert.NoError(t, err) 83 | assert.Equal(t, e.expected, actual) 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestParseRule(t *testing.T) { 90 | examples := []struct { 91 | name string 92 | rule string 93 | ownerMatchers []OwnerMatcher 94 | expected Rule 95 | err string 96 | }{ 97 | // Success cases 98 | { 99 | name: "username owners", 100 | rule: "file.txt @user", 101 | expected: Rule{ 102 | pattern: mustBuildPattern(t, "file.txt"), 103 | Owners: []Owner{{Value: "user", Type: "username"}}, 104 | }, 105 | }, 106 | { 107 | name: "team owners", 108 | rule: "file.txt @org/team", 109 | expected: Rule{ 110 | pattern: mustBuildPattern(t, "file.txt"), 111 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 112 | }, 113 | }, 114 | { 115 | name: "team owners file with parentheses", 116 | rule: "file(1).txt @org/team", 117 | expected: Rule{ 118 | pattern: mustBuildPattern(t, "file(1).txt"), 119 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 120 | }, 121 | }, 122 | { 123 | name: "team owners file with one parentheses on the left", 124 | rule: "file(1.txt @user", 125 | expected: Rule{ 126 | pattern: mustBuildPattern(t, "file(1.txt"), 127 | Owners: []Owner{{Value: "user", Type: "username"}}, 128 | }, 129 | }, 130 | { 131 | name: "team owners file with one parentheses on the right", 132 | rule: "file1).txt foo@example.com", 133 | expected: Rule{ 134 | pattern: mustBuildPattern(t, "file1).txt"), 135 | Owners: []Owner{{Value: "foo@example.com", Type: "email"}}, 136 | }, 137 | }, 138 | { 139 | name: "team owners file with parentheses in the folder name", 140 | rule: "(folder)/file.txt @org/team", 141 | expected: Rule{ 142 | pattern: mustBuildPattern(t, "(folder)/file.txt"), 143 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 144 | }, 145 | }, 146 | { 147 | name: "email owners", 148 | rule: "file.txt foo@example.com", 149 | expected: Rule{ 150 | pattern: mustBuildPattern(t, "file.txt"), 151 | Owners: []Owner{{Value: "foo@example.com", Type: "email"}}, 152 | }, 153 | }, 154 | { 155 | name: "multiple owners", 156 | rule: "file.txt @user @org/team foo@example.com", 157 | expected: Rule{ 158 | pattern: mustBuildPattern(t, "file.txt"), 159 | Owners: []Owner{ 160 | {Value: "user", Type: "username"}, 161 | {Value: "org/team", Type: "team"}, 162 | {Value: "foo@example.com", Type: "email"}, 163 | }, 164 | }, 165 | }, 166 | { 167 | name: "complex patterns", 168 | rule: "d?r/* @user", 169 | expected: Rule{ 170 | pattern: mustBuildPattern(t, "d?r/*"), 171 | Owners: []Owner{{Value: "user", Type: "username"}}, 172 | }, 173 | }, 174 | { 175 | name: "pattern with space", 176 | rule: "foo\\ bar @user", 177 | expected: Rule{ 178 | pattern: mustBuildPattern(t, "foo\\ bar"), 179 | Owners: []Owner{{Value: "user", Type: "username"}}, 180 | }, 181 | }, 182 | { 183 | name: "comments", 184 | rule: "file.txt @user # some comment", 185 | expected: Rule{ 186 | pattern: mustBuildPattern(t, "file.txt"), 187 | Owners: []Owner{{Value: "user", Type: "username"}}, 188 | Comment: "some comment", 189 | }, 190 | }, 191 | { 192 | name: "pattern with no owners", 193 | rule: "pattern", 194 | expected: Rule{ 195 | pattern: mustBuildPattern(t, "pattern"), 196 | Owners: nil, 197 | Comment: "", 198 | }, 199 | }, 200 | { 201 | name: "pattern with no owners and comment", 202 | rule: "pattern # but no more", 203 | expected: Rule{ 204 | pattern: mustBuildPattern(t, "pattern"), 205 | Owners: nil, 206 | Comment: "but no more", 207 | }, 208 | }, 209 | { 210 | name: "pattern with no owners with whitespace", 211 | rule: "pattern ", 212 | expected: Rule{ 213 | pattern: mustBuildPattern(t, "pattern"), 214 | Owners: nil, 215 | Comment: "", 216 | }, 217 | }, 218 | { 219 | name: "pattern with leading and trailing whitespace", 220 | rule: " pattern @user ", 221 | expected: Rule{ 222 | pattern: mustBuildPattern(t, "pattern"), 223 | Owners: []Owner{{Value: "user", Type: "username"}}, 224 | Comment: "", 225 | }, 226 | }, 227 | { 228 | name: "pattern with leading and trailing whitespace and no owner", 229 | rule: " pattern ", 230 | expected: Rule{ 231 | pattern: mustBuildPattern(t, "pattern"), 232 | Owners: nil, 233 | Comment: "", 234 | }, 235 | }, 236 | { 237 | name: "pattern with pipe character '|'", 238 | rule: "foo|bar|baz @org/team", 239 | expected: Rule{ 240 | pattern: mustBuildPattern(t, "foo|bar|baz"), 241 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 242 | }, 243 | }, 244 | { 245 | name: "pattern with left curly brace '{'", 246 | rule: "foo{bar.txt @org/team", 247 | expected: Rule{ 248 | pattern: mustBuildPattern(t, "foo{bar.txt"), 249 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 250 | }, 251 | }, 252 | { 253 | name: "pattern with right curly brace '}'", 254 | rule: "foo}bar.txt @org/team", 255 | expected: Rule{ 256 | pattern: mustBuildPattern(t, "foo}bar.txt"), 257 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 258 | }, 259 | }, 260 | { 261 | name: "pattern with curly braces '{' and '}'", 262 | rule: "foo{bar}.txt @org/team", 263 | expected: Rule{ 264 | pattern: mustBuildPattern(t, "foo{bar}.txt"), 265 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 266 | }, 267 | }, 268 | { 269 | name: "pattern with curly braces and pipe character", 270 | rule: "foo|{bar}.txt @org/team", 271 | expected: Rule{ 272 | pattern: mustBuildPattern(t, "foo|{bar}.txt"), 273 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 274 | }, 275 | }, 276 | { 277 | name: "pattern with tilde '~'", 278 | rule: "foobar~.txt @org/team", 279 | expected: Rule{ 280 | pattern: mustBuildPattern(t, "foobar~.txt"), 281 | Owners: []Owner{{Value: "org/team", Type: "team"}}, 282 | }, 283 | }, 284 | 285 | // Error cases 286 | { 287 | name: "empty rule", 288 | rule: "", 289 | err: "unexpected end of rule", 290 | }, 291 | { 292 | name: "patterns with brackets", 293 | rule: "file.[cC] @user", 294 | err: "unexpected character '[' at position 6", 295 | }, 296 | { 297 | name: "malformed owners", 298 | rule: "file.txt missing-at-sign", 299 | err: "invalid owner format 'missing-at-sign' at position 10", 300 | }, 301 | { 302 | name: "email owners without email matcher", 303 | rule: "file.txt foo@example.com", 304 | ownerMatchers: []OwnerMatcher{ 305 | OwnerMatchFunc(MatchTeamOwner), 306 | OwnerMatchFunc(MatchUsernameOwner), 307 | }, 308 | err: "invalid owner format 'foo@example.com' at position 10", 309 | }, 310 | { 311 | name: "team owners without team matcher", 312 | rule: "file.txt @org/team", 313 | ownerMatchers: []OwnerMatcher{ 314 | OwnerMatchFunc(MatchEmailOwner), 315 | OwnerMatchFunc(MatchUsernameOwner), 316 | }, 317 | err: "invalid owner format '@org/team' at position 10", 318 | }, 319 | { 320 | name: "username owners without username matcher", 321 | rule: "file.txt @user", 322 | ownerMatchers: []OwnerMatcher{ 323 | OwnerMatchFunc(MatchEmailOwner), 324 | OwnerMatchFunc(MatchTeamOwner), 325 | }, 326 | err: "invalid owner format '@user' at position 10", 327 | }, 328 | } 329 | 330 | for _, e := range examples { 331 | t.Run("parses "+e.name, func(t *testing.T) { 332 | opts := parseOptions{ownerMatchers: DefaultOwnerMatchers} 333 | if e.ownerMatchers != nil { 334 | opts.ownerMatchers = e.ownerMatchers 335 | } 336 | actual, err := parseRule(e.rule, opts) 337 | if e.err != "" { 338 | assert.EqualError(t, err, e.err) 339 | } else { 340 | assert.NoError(t, err) 341 | assert.Equal(t, e.expected, actual) 342 | } 343 | }) 344 | } 345 | } 346 | 347 | func mustBuildPattern(t *testing.T, pat string) pattern { 348 | p, err := newPattern(pat) 349 | if err != nil { 350 | t.Fatal(err) 351 | } 352 | return p 353 | } 354 | --------------------------------------------------------------------------------