├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── conventional-commits.yml │ ├── release-please.yml │ ├── build.yml │ ├── benchmark.yml │ └── stale.yml ├── .release-please-manifest.json ├── go.mod ├── release-please-config.json ├── LICENSE ├── README.md ├── .golangci.yml ├── CHANGELOG.md ├── go.sum ├── codeowners.go └── codeowners_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 2 | * @hairyhenderson 3 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.7.0" 3 | } 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | commit-message: 9 | prefix: deps(go) 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | open-pull-requests-limit: 10 15 | commit-message: 16 | prefix: deps(actions) 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hairyhenderson/go-codeowners 2 | 3 | go 1.22.0 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/kr/pretty v0.3.1 // indirect 10 | github.com/pmezard/go-difflib v1.0.0 // indirect 11 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 12 | gopkg.in/yaml.v3 v3.0.1 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /.github/workflows/conventional-commits.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commits 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Conventional Commits 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: webiny/action-conventional-commits@v1.3.0 15 | with: 16 | allowed-commit-types: feat,fix,docs,deps,chore,build,ci 17 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/create-github-app-token@v2 17 | id: app-token 18 | with: 19 | app-id: ${{ vars.APP_ID }} 20 | private-key: ${{ secrets.PRIVATE_KEY }} 21 | - uses: googleapis/release-please-action@v4 22 | with: 23 | token: ${{ steps.app-token.outputs.token }} 24 | config-file: release-please-config.json 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | container: 15 | image: ghcr.io/hairyhenderson/gomplate-ci-build:latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - run: go test -coverprofile=c.out -v -race ./... 19 | windows-test: 20 | runs-on: windows-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-go@v5 24 | with: 25 | go-version-file: 'go.mod' 26 | - run: go test -v ./... 27 | lint: 28 | name: lint 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-go@v5 33 | with: 34 | go-version-file: 'go.mod' 35 | - name: golangci-lint 36 | uses: golangci/golangci-lint-action@v8 37 | with: 38 | version: latest 39 | args: --verbose --max-same-issues=0 --max-issues-per-linter=0 40 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "release-type": "go", 4 | "changelog-type": "default", 5 | "changelog-sections": [ 6 | { 7 | "type": "feat", 8 | "section": "Features" 9 | }, 10 | { 11 | "type": "fix", 12 | "section": "Bug Fixes" 13 | }, 14 | { 15 | "type": "docs", 16 | "section": "Documentation" 17 | }, 18 | { 19 | "type": "deps", 20 | "section": "Dependencies" 21 | }, 22 | { 23 | "type": "chore", 24 | "section": "Miscellaneous Chores", 25 | "hidden": true 26 | }, 27 | { 28 | "type": "build", 29 | "section": "Build System", 30 | "hidden": true 31 | }, 32 | { 33 | "type": "ci", 34 | "section": "Continuous Integration", 35 | "hidden": true 36 | } 37 | ], 38 | "packages": { 39 | ".": {} 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2024 Dave Henderson 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: go benchmarks 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | benchmark: 10 | name: benchmark regression check 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: Run benchmark 18 | run: go test -benchmem -run=^$ -count=5 -benchtime=2s -bench . | tee output.txt 19 | # - name: Download previous benchmark data 20 | # uses: actions/cache@v4 21 | # with: 22 | # path: ./cache 23 | # key: ${{ runner.os }}-benchmark 24 | - name: Store benchmark result 25 | uses: benchmark-action/github-action-benchmark@v1 26 | with: 27 | tool: 'go' 28 | output-file-path: output.txt 29 | # external-data-json-path: ./cache/benchmark-data.json 30 | fail-on-alert: true 31 | comment-on-alert: true 32 | comment-always: true 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | alert-threshold: "200%" 35 | auto-push: true 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Reference][doc-image]][docs] 2 | [![Build][build-image]][build-url] 3 | 4 | # go-codeowners 5 | 6 | A package that finds and parses [`CODEOWNERS`](https://help.github.com/articles/about-codeowners/) files. 7 | 8 | Features: 9 | - operates on local repos 10 | - doesn't require a cloned repo (i.e. doesn't need a `.git` directory to be 11 | present at the repo's root) 12 | - can be called from within a repo (doesn't have to be at the root) 13 | - will find `CODEOWNERS` files in all documented locations: the repo's root, 14 | `docs/`, and `.github/` (or `.gitlab/` for GitLab repos) 15 | 16 | ## Usage 17 | 18 | ```console 19 | go get -u github.com/hairyhenderson/go-codeowners 20 | ``` 21 | 22 | To find the owner of the README.md file: 23 | 24 | ```go 25 | import "github.com/hairyhenderson/go-codeowners" 26 | 27 | func main() { 28 | c, _ := FromFile(cwd()) 29 | owners := c.Owners("README.md") 30 | for i, o := range owners { 31 | fmt.Printf("Owner #%d is %s\n", i, o) 32 | } 33 | } 34 | ``` 35 | 36 | See the [docs][] for more information. 37 | 38 | ## License 39 | 40 | [The MIT License](http://opensource.org/licenses/MIT) 41 | 42 | Copyright (c) 2018-2023 Dave Henderson 43 | 44 | [docs]: https://pkg.go.dev/github.com/hairyhenderson/go-codeowners 45 | [doc-image]: https://pkg.go.dev/badge/github.com/hairyhenderson/go-codeowners.svg 46 | 47 | [build-image]: https://github.com/hairyhenderson/go-codeowners/actions/workflows/build.yml/badge.svg 48 | [build-url]: https://github.com/hairyhenderson/go-codeowners/actions/workflows/build.yml 49 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - asciicheck 6 | - bodyclose 7 | - copyloopvar 8 | - dupl 9 | - errcheck 10 | - exhaustive 11 | - funlen 12 | - gochecknoglobals 13 | - gochecknoinits 14 | - gocognit 15 | - goconst 16 | - gocritic 17 | - gocyclo 18 | - godox 19 | - goheader 20 | - gomodguard 21 | - goprintffuncname 22 | - gosec 23 | - govet 24 | - ineffassign 25 | - lll 26 | - misspell 27 | - nakedret 28 | - nestif 29 | - noctx 30 | - nolintlint 31 | - prealloc 32 | - revive 33 | - rowserrcheck 34 | - sqlclosecheck 35 | - staticcheck 36 | - unconvert 37 | - unparam 38 | - unused 39 | - whitespace 40 | settings: 41 | dupl: 42 | threshold: 100 43 | goconst: 44 | min-len: 2 45 | min-occurrences: 4 46 | gocyclo: 47 | min-complexity: 10 48 | govet: 49 | enable-all: true 50 | lll: 51 | line-length: 140 52 | nolintlint: 53 | require-explanation: false 54 | require-specific: false 55 | allow-unused: false 56 | exclusions: 57 | generated: lax 58 | presets: 59 | - comments 60 | - common-false-positives 61 | - legacy 62 | - std-error-handling 63 | paths: 64 | - third_party$ 65 | - builtin$ 66 | - examples$ 67 | formatters: 68 | enable: 69 | - gci 70 | - gofmt 71 | - goimports 72 | exclusions: 73 | generated: lax 74 | paths: 75 | - third_party$ 76 | - builtin$ 77 | - examples$ 78 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Stale issue handler' 2 | on: 3 | workflow_dispatch: 4 | issue_comment: 5 | schedule: 6 | - cron: '42 04 * * *' 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@v9 17 | # See https://github.com/actions/stale#all-options 18 | with: 19 | exempt-all-milestones: true 20 | days-before-stale: 60 21 | days-before-close: 14 22 | stale-issue-message: | 23 | This issue is stale because it has been open for 60 days with no 24 | activity. If it is no longer relevant or necessary, please close it. 25 | Given no action, it will be closed in 14 days. 26 | 27 | If it's still relevant, one of the following will remove the stale 28 | marking: 29 | - A maintainer can add this issue to a milestone to indicate that 30 | it's been accepted and will be worked on 31 | - A maintainer can remove the `stale` label 32 | - Anyone can post an update or other comment 33 | stale-pr-message: | 34 | This pull request is stale because it has been open for 60 days with 35 | no activity. If it is no longer relevant or necessary, please close 36 | it. Given no action, it will be closed in 14 days. 37 | 38 | If it's still relevant, one of the following will remove the stale 39 | marking: 40 | - A maintainer can add this pull request to a milestone to indicate 41 | that it's been accepted and will be worked on 42 | - A maintainer can remove the `stale` label 43 | - Anyone can post an update or other comment 44 | - Anyone with write access can push a commit to the pull request 45 | branch 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.7.0](https://github.com/hairyhenderson/go-codeowners/compare/v0.6.1...v0.7.0) (2024-12-07) 4 | 5 | 6 | ### Features 7 | 8 | * add PatternIndex method ([#48](https://github.com/hairyhenderson/go-codeowners/issues/48)) ([a5c2e59](https://github.com/hairyhenderson/go-codeowners/commit/a5c2e593dcbe38fa52c24c95a72d1ca3f5e47ee8)) 9 | 10 | 11 | ### Dependencies 12 | 13 | * **go:** Bump github.com/stretchr/testify from 1.9.0 to 1.10.0 ([#49](https://github.com/hairyhenderson/go-codeowners/issues/49)) ([4f55176](https://github.com/hairyhenderson/go-codeowners/commit/4f551769326fc31237d06b29a4a0497c78bd882f)) 14 | 15 | ## [0.6.1](https://github.com/hairyhenderson/go-codeowners/compare/v0.6.0...v0.6.1) (2024-10-25) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **parsing:** Check for CODEOWNERS files for both GitHub and GitLab ([#45](https://github.com/hairyhenderson/go-codeowners/issues/45)) ([db9c01e](https://github.com/hairyhenderson/go-codeowners/commit/db9c01eb61a0e5975521721a102e8c54b6dc2876)), closes [#44](https://github.com/hairyhenderson/go-codeowners/issues/44) 21 | * **parsing:** Ignore block and inline comments ([#46](https://github.com/hairyhenderson/go-codeowners/issues/46)) ([968c9ea](https://github.com/hairyhenderson/go-codeowners/commit/968c9eaf0924c1912731d2729f26d2c691b8d4b1)) 22 | 23 | ## [0.6.0](https://github.com/hairyhenderson/go-codeowners/compare/v0.5.0...v0.6.0) (2024-09-27) 24 | 25 | 26 | ### Features 27 | 28 | * **errors:** Allow checking codeowners file not found ([#41](https://github.com/hairyhenderson/go-codeowners/issues/41)) ([d659b73](https://github.com/hairyhenderson/go-codeowners/commit/d659b73a08c1a1111c5e9c4c1136472c8ca28a4b)) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * **perf:** Reduce allocations ([#39](https://github.com/hairyhenderson/go-codeowners/issues/39)) ([2ca66e0](https://github.com/hairyhenderson/go-codeowners/commit/2ca66e0194d2b9077c63a9d1eace8f1083675fa9)) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 5 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 6 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 7 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 8 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 9 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 10 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 11 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 15 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 16 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 17 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 20 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 21 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 22 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /codeowners.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | ) 15 | 16 | // ErrNoCodeownersFound is returned when no CODEOWNERS file is found 17 | var ErrNoCodeownersFound = errors.New("no CODEOWNERS found") 18 | 19 | // Codeowners - patterns/owners mappings for the given repo 20 | type Codeowners struct { 21 | repoRoot string 22 | Patterns []Codeowner 23 | } 24 | 25 | // Codeowner - owners for a given pattern 26 | type Codeowner struct { 27 | Pattern string 28 | re *regexp.Regexp 29 | Owners []string 30 | } 31 | 32 | func (c Codeowner) String() string { 33 | return fmt.Sprintf("%s\t%v", c.Pattern, strings.Join(c.Owners, ", ")) 34 | } 35 | 36 | func dirExists(fsys fs.FS, path string) (bool, error) { 37 | fi, err := fs.Stat(fsys, path) 38 | if err == nil && fi.IsDir() { 39 | return true, nil 40 | } 41 | 42 | if errors.Is(err, fs.ErrNotExist) { 43 | return false, nil 44 | } 45 | 46 | return false, err 47 | } 48 | 49 | // findCodeownersFile - find a CODEOWNERS file somewhere within or below 50 | // the working directory (wd), and open it. 51 | func findCodeownersFile(fsys fs.FS, wd string) (io.Reader, string, error) { 52 | dir := wd 53 | for { 54 | for _, p := range []string{".github", ".", "docs", ".gitlab"} { 55 | pth := path.Join(dir, p) 56 | exists, err := dirExists(fsys, pth) 57 | if err != nil { 58 | return nil, "", err 59 | } 60 | if exists { 61 | f := path.Join(pth, "CODEOWNERS") 62 | _, err := fs.Stat(fsys, f) 63 | if err != nil { 64 | if errors.Is(err, fs.ErrNotExist) { 65 | continue 66 | } 67 | return nil, "", err 68 | } 69 | r, err := fsys.Open(f) 70 | return r, dir, err 71 | } 72 | } 73 | odir := dir 74 | dir = path.Dir(odir) 75 | // if we can't go up any further... 76 | if odir == dir { 77 | break 78 | } 79 | // if we're heading above the volume name (relevant on Windows)... 80 | if len(dir) < len(filepath.VolumeName(odir)) { 81 | break 82 | } 83 | } 84 | return nil, "", nil 85 | } 86 | 87 | // Deprecated: Use [FromFile] instead. 88 | func NewCodeowners(path string) (*Codeowners, error) { 89 | return FromFile(path) 90 | } 91 | 92 | // FromFile creates a Codeowners from the path to a local file. Consider using 93 | // [FromFileWithFS] instead. 94 | func FromFile(path string) (*Codeowners, error) { 95 | base := "/" 96 | if filepath.IsAbs(path) && filepath.VolumeName(path) != "" { 97 | base = path[:len(filepath.VolumeName(path))+1] 98 | } 99 | path = path[len(base):] 100 | 101 | return FromFileWithFS(os.DirFS(base), path) 102 | } 103 | 104 | // FromFileWithFS creates a Codeowners from the path to a file relative to the 105 | // given filesystem. 106 | func FromFileWithFS(fsys fs.FS, path string) (*Codeowners, error) { 107 | r, root, err := findCodeownersFile(fsys, path) 108 | if err != nil { 109 | return nil, err 110 | } 111 | if r == nil { 112 | return nil, fmt.Errorf("%w in %s", ErrNoCodeownersFound, path) 113 | } 114 | return FromReader(r, root) 115 | } 116 | 117 | // FromReader creates a Codeowners from a given Reader instance and root path. 118 | func FromReader(r io.Reader, repoRoot string) (*Codeowners, error) { 119 | co := &Codeowners{ 120 | repoRoot: repoRoot, 121 | } 122 | patterns, err := parseCodeowners(r) 123 | if err != nil { 124 | return nil, err 125 | } 126 | co.Patterns = patterns 127 | return co, nil 128 | } 129 | 130 | func isSection(line string) bool { 131 | return strings.HasPrefix(line, "^") || strings.HasPrefix(line, "[") 132 | } 133 | 134 | // parseCodeowners parses a list of Codeowners from a Reader 135 | func parseCodeowners(r io.Reader) ([]Codeowner, error) { 136 | co := []Codeowner{} 137 | s := bufio.NewScanner(r) 138 | var defaultOwners []string 139 | for s.Scan() { 140 | line := s.Text() 141 | if isSection(line) { 142 | defaultOwners = parseDefaultOwners(line) 143 | continue 144 | } 145 | fields := strings.Fields(line) 146 | 147 | fields = removeComments(fields) 148 | if len(fields) == 0 { 149 | continue 150 | } 151 | if len(fields) > 1 { 152 | fields = combineEscapedSpaces(fields) 153 | c, err := NewCodeowner(fields[0], fields[1:]) 154 | if err != nil { 155 | return nil, err 156 | } 157 | co = append(co, c) 158 | } else if len(fields) == 1 && defaultOwners != nil { 159 | c, err := NewCodeowner(fields[0], defaultOwners) 160 | if err != nil { 161 | return nil, err 162 | } 163 | co = append(co, c) 164 | } 165 | } 166 | return co, nil 167 | } 168 | 169 | func removeComments(fields []string) []string { 170 | for i := range fields { 171 | if strings.HasPrefix(fields[i], "#") { 172 | fields = fields[:i] 173 | break 174 | } 175 | } 176 | return fields 177 | } 178 | 179 | func parseDefaultOwners(line string) []string { 180 | index := strings.LastIndex(line, "]") 181 | if index != -1 && len(line) > index+1 { 182 | return strings.Fields(strings.TrimSpace(line[index+1:])) 183 | } 184 | return nil 185 | } 186 | 187 | // if any of the elements ends with a \, it was an escaped space 188 | // put it back together properly so it's not treated as separate fields 189 | func combineEscapedSpaces(fields []string) []string { 190 | outFields := make([]string, 0) 191 | escape := `\` 192 | for i := 0; i < len(fields); i++ { 193 | outField := fields[i] 194 | for strings.HasSuffix(fields[i], escape) && i+1 < len(fields) { 195 | outField = strings.Join([]string{strings.TrimRight(outField, escape), fields[i+1]}, " ") 196 | i++ 197 | } 198 | outFields = append(outFields, outField) 199 | } 200 | 201 | return outFields 202 | } 203 | 204 | // NewCodeowner - 205 | func NewCodeowner(pattern string, owners []string) (Codeowner, error) { 206 | re, err := getPattern(pattern) 207 | if err != nil { 208 | return Codeowner{}, err 209 | } 210 | c := Codeowner{ 211 | Pattern: pattern, 212 | re: re, 213 | Owners: owners, 214 | } 215 | return c, nil 216 | } 217 | 218 | // Owners - return the list of code owners for the given path 219 | // (within the repo root) 220 | func (c *Codeowners) Owners(path string) []string { 221 | if i := c.PatternIndex(path); i >= 0 { 222 | return c.Patterns[i].Owners 223 | } 224 | return nil 225 | } 226 | 227 | // PatternIndex - return the index of the pattern that matches the given path 228 | // (within the repo root) 229 | func (c *Codeowners) PatternIndex(path string) int { 230 | if strings.HasPrefix(path, c.repoRoot) { 231 | path = strings.Replace(path, c.repoRoot, "", 1) 232 | } 233 | 234 | // Order is important; the last matching pattern takes the most precedence. 235 | for i := len(c.Patterns) - 1; i >= 0; i-- { 236 | if c.Patterns[i].re.MatchString(path) { 237 | return i 238 | } 239 | } 240 | 241 | return -1 242 | } 243 | 244 | // precompile all regular expressions 245 | var ( 246 | rePrependSlash = regexp.MustCompile(`([^\/+])/.*\*\.`) 247 | ) 248 | 249 | // based on github.com/sabhiram/go-gitignore 250 | // but modified so that 'dir/*' only matches files in 'dir/' 251 | func getPattern(line string) (*regexp.Regexp, error) { 252 | // when # or ! is escaped with a \ 253 | if strings.HasPrefix(line, `\#`) || strings.HasPrefix(line, `\!`) { 254 | line = line[1:] 255 | } 256 | 257 | // If we encounter a foo/*.blah in a folder, prepend the / char 258 | if rePrependSlash.MatchString(line) && line[0] != '/' { 259 | line = "/" + line 260 | } 261 | 262 | // Handle escaping the "." char 263 | line = strings.ReplaceAll(line, ".", `\.`) 264 | 265 | magicStar := "#$~" 266 | 267 | // Handle "/**/" usage 268 | if strings.HasPrefix(line, "/**/") { 269 | line = line[1:] 270 | } 271 | line = strings.ReplaceAll(line, `/**/`, `(/|/.+/)`) 272 | line = strings.ReplaceAll(line, `**/`, `(|.`+magicStar+`/)`) 273 | line = strings.ReplaceAll(line, `/**`, `(|/.`+magicStar+`)`) 274 | 275 | // Handle escaping the "*" char 276 | line = strings.ReplaceAll(line, `\*`, `\`+magicStar) 277 | line = strings.ReplaceAll(line, `*`, `([^/]*)`) 278 | 279 | // Handle escaping the "?" char 280 | line = strings.ReplaceAll(line, "?", `\?`) 281 | 282 | line = strings.ReplaceAll(line, magicStar, "*") 283 | 284 | // Temporary regex 285 | expr := "" 286 | 287 | switch { 288 | case strings.HasSuffix(line, "/"): 289 | expr = line + "(|.*)$" 290 | case strings.HasSuffix(line, "/([^/]*)"): 291 | expr = line + "$" 292 | default: 293 | expr = line + "($|/.*$)" 294 | } 295 | 296 | if strings.HasPrefix(expr, "/") { 297 | expr = "^(|/)" + expr[1:] 298 | } else { 299 | expr = "^(|.*/)" + expr 300 | } 301 | 302 | return regexp.Compile(expr) 303 | } 304 | -------------------------------------------------------------------------------- /codeowners_test.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | "testing/fstest" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | //nolint:gochecknoglobals 19 | var ( 20 | sample = `# comment 21 | * @everyone 22 | 23 | foobar/ someone@else.com # inline comment 24 | 25 | docs/** @org/docteam @joe` 26 | sample2 = `* @hairyhenderson` 27 | sample3 = `baz/* @baz @qux` 28 | sample4 = `[test] 29 | * @everyone 30 | [test2][2] 31 | */foo @everyoneelse` 32 | 33 | // based on https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 34 | // with a few unimportant modifications 35 | fullSample = `# This is a comment. 36 | # Each line is a file pattern followed by one or more owners. 37 | 38 | # These owners will be the default owners for everything in 39 | # the repo. Unless a later match takes precedence, 40 | # @global-owner1 and @global-owner2 will be requested for 41 | # review when someone opens a pull request. 42 | * @global-owner1 @global-owner2 43 | 44 | # Order is important; the last matching pattern takes the most 45 | # precedence. When someone opens a pull request that only 46 | # modifies JS files, only @js-owner and not the global 47 | # owner(s) will be requested for a review. 48 | *.js @js-owner 49 | 50 | # You can also use email addresses if you prefer. They'll be 51 | # used to look up users just like we do for commit author 52 | # emails. 53 | *.go docs@example.com 54 | 55 | # In this example, @doctocat owns any files in the build/logs 56 | # directory at the root of the repository and any of its 57 | # subdirectories. 58 | /build/logs/ @doctocat 59 | 60 | # In this example, @fooowner owns any files in the /cells/foo 61 | # directory at the root of the repository and any of its 62 | # subdirectories and files. 63 | /cells/foo @fooowner 64 | 65 | # The 'docs/*' pattern will match files like 66 | # 'docs/getting-started.md' but not further nested files like 67 | # 'docs/build-app/troubleshooting.md'. 68 | docs/* docs@example.com 69 | 70 | # In this example, @octocat owns any file in an apps directory 71 | # anywhere in your repository. 72 | apps/ @octocat 73 | 74 | # In this example, @doctocat owns any file in the '/docs' 75 | # directory in the root of your repository. 76 | /docs/ @doctocat 77 | 78 | foobar/ @fooowner 79 | 80 | \#foo/ @hashowner 81 | 82 | docs/*.md @mdowner 83 | 84 | # this example tests an escaped space in the path 85 | space/test\ space/ @spaceowner 86 | 87 | # In this example, @infra owns any file and directory in the 88 | # '/terraform' directory in the root of your repository. 89 | /terraform @infra 90 | ` 91 | 92 | gitlabSections = `# This is a GitLab section with default owners. 93 | [Team 1][1] @default1 @default2 94 | *.js @js-owner 95 | *.txt 96 | 97 | # This is another section with new defaults. 98 | [Team 2] @default3 @default4 99 | *.java @java-owner 100 | * 101 | 102 | # This is an optional sections without defaults. 103 | ^[Team 3] 104 | *.go @team-1 105 | ` 106 | 107 | codeowners []Codeowner 108 | ) 109 | 110 | func TestParseGitLabSectionsWithDefaults(t *testing.T) { 111 | t.Parallel() 112 | r := bytes.NewBufferString(gitlabSections) 113 | c, _ := parseCodeowners(r) 114 | expected := []Codeowner{ 115 | co("*.js", []string{"@js-owner"}), 116 | co("*.txt", []string{"@default1", "@default2"}), 117 | co("*.java", []string{"@java-owner"}), 118 | co("*", []string{"@default3", "@default4"}), 119 | co("*.go", []string{"@team-1"}), 120 | } 121 | assert.Equal(t, expected, c) 122 | } 123 | 124 | func TestParseCodeowners(t *testing.T) { 125 | t.Parallel() 126 | r := bytes.NewBufferString(sample) 127 | c, _ := parseCodeowners(r) 128 | expected := []Codeowner{ 129 | co("*", []string{"@everyone"}), 130 | co("foobar/", []string{"someone@else.com"}), 131 | co("docs/**", []string{"@org/docteam", "@joe"}), 132 | } 133 | assert.Equal(t, expected, c) 134 | } 135 | 136 | func TestParseCodeownersSections(t *testing.T) { 137 | t.Parallel() 138 | r := bytes.NewBufferString(sample4) 139 | c, _ := parseCodeowners(r) 140 | expected := []Codeowner{ 141 | co("*", []string{"@everyone"}), 142 | co("*/foo", []string{"@everyoneelse"}), 143 | } 144 | assert.Equal(t, expected, c) 145 | } 146 | 147 | func BenchmarkParseCodeowners(b *testing.B) { 148 | var c []Codeowner 149 | 150 | for n := 0; n < b.N; n++ { 151 | b.StopTimer() 152 | r := bytes.NewBufferString(sample) 153 | b.StartTimer() 154 | c, _ = parseCodeowners(r) 155 | } 156 | 157 | codeowners = c 158 | } 159 | 160 | func BenchmarkOwners(b *testing.B) { 161 | c, _ := FromReader(strings.NewReader(fullSample), "") 162 | data := []string{ 163 | "#foo/bar.go", 164 | "blah/docs/README.md", 165 | "foo/bar/docs/foo/foo.js", 166 | "/space/test space/doc1.txt", 167 | "/terraform/kubernetes", 168 | } 169 | 170 | for _, d := range data { 171 | b.Run(d, func(b *testing.B) { 172 | for n := 0; n < b.N; n++ { 173 | _ = c.Owners(d) 174 | } 175 | }) 176 | } 177 | } 178 | 179 | func TestFindCodeownersFile(t *testing.T) { 180 | fsys := fstest.MapFS{ 181 | "src/.github/CODEOWNERS": &fstest.MapFile{Data: []byte(sample)}, 182 | "src/foo/CODEOWNERS": &fstest.MapFile{Data: []byte(sample2)}, 183 | "src/foo/qux/docs/CODEOWNERS": &fstest.MapFile{Data: []byte(sample3)}, 184 | "src/bar/CODEOWNERS": &fstest.MapFile{Data: []byte(sample2)}, 185 | "src/bar/.github/CODEOWNERS": &fstest.MapFile{Data: []byte(sample)}, 186 | "src/bar/.gitlab/CODEOWNERS": &fstest.MapFile{Data: []byte(sample3)}, 187 | } 188 | 189 | r, root, err := findCodeownersFile(fsys, "src") 190 | require.NoError(t, err) 191 | assert.NotNil(t, r) 192 | assert.Equal(t, "src", root) 193 | 194 | b, _ := io.ReadAll(r) 195 | assert.Equal(t, sample, string(b)) 196 | 197 | r, root, err = findCodeownersFile(fsys, "src/foo/bar") 198 | require.NoError(t, err) 199 | assert.NotNil(t, r) 200 | assert.Equal(t, "src/foo", root) 201 | 202 | b, _ = io.ReadAll(r) 203 | assert.Equal(t, sample2, string(b)) 204 | 205 | r, root, err = findCodeownersFile(fsys, "src/foo/qux/quux") 206 | require.NoError(t, err) 207 | assert.NotNil(t, r) 208 | assert.Equal(t, "src/foo/qux", root) 209 | 210 | b, _ = io.ReadAll(r) 211 | assert.Equal(t, sample3, string(b)) 212 | 213 | r, _, err = findCodeownersFile(fsys, ".") 214 | require.NoError(t, err) 215 | assert.Nil(t, r) 216 | 217 | r, root, err = findCodeownersFile(fsys, "src/bar") 218 | require.NoError(t, err) 219 | assert.NotNil(t, r) 220 | assert.Equal(t, "src/bar", root) 221 | 222 | b, _ = io.ReadAll(r) 223 | assert.Equal(t, sample, string(b)) 224 | } 225 | 226 | func co(pattern string, owners []string) Codeowner { 227 | c, err := NewCodeowner(pattern, owners) 228 | if err != nil { 229 | panic(err) 230 | } 231 | return c 232 | } 233 | 234 | func TestFullParseCodeowners(t *testing.T) { 235 | t.Parallel() 236 | 237 | c, _ := parseCodeowners(strings.NewReader(fullSample)) 238 | codeowners := &Codeowners{ 239 | repoRoot: "/build", 240 | Patterns: c, 241 | } 242 | 243 | // these tests were ported from https://github.com/softprops/codeowners 244 | data := []struct { 245 | path string 246 | owners []string 247 | }{ 248 | {"#foo/bar.go", []string{"@hashowner"}}, 249 | {"foobar/baz.go", []string{"@fooowner"}}, 250 | {"/docs/README.md", []string{"@mdowner"}}, 251 | // XXX: uncertain about this one 252 | {"blah/docs/README.md", []string{"docs@example.com"}}, 253 | {"foo.txt", []string{"@global-owner1", "@global-owner2"}}, 254 | {"foo/bar.txt", []string{"@global-owner1", "@global-owner2"}}, 255 | {"foo.js", []string{"@js-owner"}}, 256 | {"foo/bar.js", []string{"@js-owner"}}, 257 | {"foo.go", []string{"docs@example.com"}}, 258 | {"foo/bar.go", []string{"docs@example.com"}}, 259 | // relative to root 260 | {"build/logs/foo.go", []string{"@doctocat"}}, 261 | {"build/logs/foo/bar.go", []string{"@doctocat"}}, 262 | // not relative to root 263 | {"foo/build/logs/foo.go", []string{"docs@example.com"}}, 264 | // docs anywhere 265 | {"foo/docs/foo.js", []string{"docs@example.com"}}, 266 | {"foo/bar/docs/foo.js", []string{"docs@example.com"}}, 267 | // but not nested 268 | {"foo/bar/docs/foo/foo.js", []string{"@js-owner"}}, 269 | {"foo/apps/foo.js", []string{"@octocat"}}, 270 | {"docs/foo.js", []string{"@doctocat"}}, 271 | {"/docs/foo.js", []string{"@doctocat"}}, 272 | {"/space/test space/doc1.txt", []string{"@spaceowner"}}, 273 | {"/terraform/kubernetes", []string{"@infra"}}, 274 | 275 | {"/cells/foo", []string{"@fooowner"}}, 276 | {"/cells/foo/", []string{"@fooowner"}}, 277 | {"/cells/foo/bar", []string{"@fooowner"}}, 278 | {"/cells/foo/bar/quux", []string{"@fooowner"}}, 279 | } 280 | 281 | for _, d := range data { 282 | t.Run(fmt.Sprintf("%q==%#v", d.path, d.owners), func(t *testing.T) { 283 | assert.EqualValues(t, d.owners, codeowners.Owners(d.path)) 284 | }) 285 | } 286 | } 287 | 288 | func TestOwners(t *testing.T) { 289 | foo := []string{"@foo"} 290 | bar := []string{"@bar"} 291 | baz := []string{"@baz"} 292 | data := []struct { 293 | patterns []Codeowner 294 | path string 295 | expected []string 296 | }{ 297 | {[]Codeowner{co("a/*", foo)}, "c/b", nil}, 298 | {[]Codeowner{co("**", foo)}, "a/b", foo}, 299 | {[]Codeowner{co("**", foo), co("a/b/*", bar)}, "a/b/c", bar}, 300 | {[]Codeowner{co("**", foo), co("a/b/*", bar), co("a/b/c", baz)}, "a/b/c", baz}, 301 | {[]Codeowner{co("**", foo), co("a/*/c", bar), co("a/b/*", baz)}, "a/b/c", baz}, 302 | {[]Codeowner{co("**", foo), co("a/b/*", bar), co("a/b/", baz)}, "a/b/bar", baz}, 303 | {[]Codeowner{co("**", foo), co("a/b/*", bar), co("a/b/", baz)}, "/someroot/a/b/bar", baz}, 304 | {[]Codeowner{ 305 | co("*", foo), 306 | co("/a/*", bar), 307 | co("/b/**", baz)}, "/a/aa/file", foo}, 308 | {[]Codeowner{ 309 | co("*", foo), 310 | co("/a/**", bar)}, "/a/bb/file", bar}, 311 | {[]Codeowner{ 312 | co("*", []string{"@foo", "@bar"}), 313 | co("/bar/", bar)}, "/bar/quux", bar}, 314 | {[]Codeowner{ 315 | co("*", []string{"@foo", "@bar"}), 316 | co("/bar", bar)}, "/bar", bar}, 317 | {[]Codeowner{ 318 | co("*", []string{"@foo", "@bar"}), 319 | co("/bar", bar)}, "/bar/quux", bar}, 320 | } 321 | 322 | for _, d := range data { 323 | t.Run(fmt.Sprintf("%s==%s", d.path, d.expected), func(t *testing.T) { 324 | c := &Codeowners{Patterns: d.patterns, repoRoot: "/someroot"} 325 | owners := c.Owners(d.path) 326 | assert.Equal(t, d.expected, owners) 327 | }) 328 | } 329 | } 330 | 331 | func TestCombineEscapedSpaces(t *testing.T) { 332 | data := []struct { 333 | fields []string 334 | expected []string 335 | }{ 336 | {[]string{"docs/", "@owner"}, []string{"docs/", "@owner"}}, 337 | {[]string{"docs/bob/**", "@owner"}, []string{"docs/bob/**", "@owner"}}, 338 | {[]string{"docs/bob\\", "test/", "@owner"}, []string{"docs/bob test/", "@owner"}}, 339 | {[]string{"docs/bob\\", "test/sub/final\\", "space/", "@owner"}, []string{"docs/bob test/sub/final space/", "@owner"}}, 340 | {[]string{"docs/bob\\", "test/another\\", "test/**", "@owner"}, []string{"docs/bob test/another test/**", "@owner"}}, 341 | } 342 | 343 | for _, d := range data { 344 | t.Run(fmt.Sprintf("%s==%s", d.fields, d.expected), func(t *testing.T) { 345 | assert.Equal(t, d.expected, combineEscapedSpaces(d.fields)) 346 | }) 347 | } 348 | } 349 | 350 | func cwd() string { 351 | _, filename, _, _ := runtime.Caller(0) 352 | cwd := path.Dir(filename) 353 | return cwd 354 | } 355 | 356 | func ExampleFromFile() { 357 | c, _ := FromFile(cwd()) 358 | fmt.Println(c.Patterns[0]) 359 | // Output: 360 | // * @hairyhenderson 361 | } 362 | 363 | func ExampleFromFileWithFS() { 364 | // open filesystem rooted at current working directory 365 | fsys := os.DirFS(cwd()) 366 | 367 | c, _ := FromFileWithFS(fsys, ".") 368 | fmt.Println(c.Patterns[0]) 369 | // Output: 370 | // * @hairyhenderson 371 | } 372 | 373 | func ExampleFromReader() { 374 | reader := strings.NewReader(sample2) 375 | c, _ := FromReader(reader, "") 376 | fmt.Println(c.Patterns[0]) 377 | // Output: 378 | // * @hairyhenderson 379 | } 380 | 381 | func ExampleCodeowners_Owners() { 382 | c, _ := FromFile(cwd()) 383 | owners := c.Owners("README.md") 384 | for i, o := range owners { 385 | fmt.Printf("Owner #%d is %s\n", i, o) 386 | } 387 | // Output: 388 | // Owner #0 is @hairyhenderson 389 | } 390 | --------------------------------------------------------------------------------