├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull-request-template.md └── workflows │ ├── codeql-analysis.yml │ ├── pull-requests.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── action.yml ├── docs.json ├── docs ├── README.md ├── assets │ ├── action-output.png │ ├── logo-small.png │ ├── logo.png │ ├── token-private.png │ ├── token-public.png │ └── usage.svg ├── development.md ├── gh-action.md ├── gh-auth.md ├── investigation │ └── file_exists_checker │ │ ├── file_matcher_libs_bench_test.go │ │ ├── glob.md │ │ ├── go.mod │ │ └── go.sum └── release.md ├── go.mod ├── go.sum ├── hack ├── README.md ├── compress.sh ├── lib │ └── utilities.sh ├── run-lint.sh ├── run-test-integration.sh └── run-test-unit.sh ├── install.sh ├── internal ├── check │ ├── api.go │ ├── api_test.go │ ├── avoid_shadowing.go │ ├── avoid_shadowing_test.go │ ├── duplicated_pattern.go │ ├── duplicated_pattern_test.go │ ├── file_exists.go │ ├── file_exists_test.go │ ├── helpers_test.go │ ├── not_owned_file.go │ ├── package_test.go │ ├── valid_owner.go │ ├── valid_owner_error.go │ ├── valid_owner_export_test.go │ ├── valid_owner_test.go │ ├── valid_syntax.go │ └── valid_syntax_test.go ├── ctxutil │ ├── check.go │ └── check_test.go ├── envconfig │ ├── envconfig.go │ └── envconfig_test.go ├── github │ └── client.go ├── load │ └── load.go ├── printer │ ├── testdata │ │ ├── TestTTYPrinterPrintCheckResult │ │ │ ├── Should_print_OK_status_on_empty_issues_list.golden.txt │ │ │ └── Should_print_all_reported_issues.golden.txt │ │ └── TestTTYPrinterPrintSummary │ │ │ ├── Should_print_no_when_there_is_no_failures.golden.txt │ │ │ └── Should_print_number_of_failures.golden.txt │ ├── tty.go │ └── tty_test.go ├── ptr │ └── uint.go └── runner │ └── runner_worker.go ├── main.go ├── pkg ├── codeowners │ ├── export_test.go │ ├── owners.go │ ├── owners_example_test.go │ ├── owners_test.go │ └── testdata │ │ └── CODEOWNERS └── url │ ├── canonical.go │ └── canonical_test.go └── tests └── integration ├── helpers_test.go ├── integration_test.go └── testdata ├── TestCheckFailures ├── avoid-shadowing.golden.txt ├── duppatterns.golden.txt ├── files.golden.txt ├── notowned.golden.txt ├── notowned_sub_dirs.golden.txt └── owners.golden.txt ├── TestCheckSuccess ├── offline_checks │ ├── GitHubCODEOWNERS │ │ ├── avoid-shadowing.golden.txt │ │ ├── duppatterns.golden.txt │ │ ├── files.golden.txt │ │ └── notowned.golden.txt │ └── gh-codeowners │ │ ├── avoid-shadowing.golden.txt │ │ ├── duppatterns.golden.txt │ │ ├── files.golden.txt │ │ └── notowned.golden.txt └── online_checks │ ├── GitHubCODEOWNERS │ └── owners.golden.txt │ └── gh-codeowners │ └── owners.golden.txt ├── TestGitHubAppAuth.golden.txt └── TestOwnerCheckAuthZAndAuthN ├── invalid_token_specified.golden.txt ├── token_not_specified.golden.txt ├── token_specified_but_without_necessary_scopes.golden.txt └── token_specified_but_without_necessary_scopes_and_against_private_repo.golden.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | ** 3 | 4 | # Allow binary 5 | !/codeowners-validator 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in the project 4 | title: '' 5 | labels: bug 6 | assignees: mszostok 7 | --- 8 | 9 | 10 | 11 | **Description** 12 | 13 | 15 | 16 | 17 | 18 | **Expected result** 19 | 20 | 21 | 22 | **Actual result** 23 | 24 | 25 | 26 | **Steps to reproduce** 27 | 28 | 29 | 30 | **Troubleshooting** 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an improvement to the project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Description** 13 | 14 | 15 | 16 | **Reasons** 17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for Go 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | 14 | # Maintain dependencies for build tools 15 | - package-ecosystem: "gomod" 16 | directory: "/tools" 17 | schedule: 18 | interval: "monthly" 19 | 20 | # Maintain dependencies for GitHub Actions 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "monthly" 25 | 26 | # Maintain dependencies for Dockerfile 27 | - package-ecosystem: "docker" 28 | directory: "/" 29 | schedule: 30 | interval: "monthly" 31 | -------------------------------------------------------------------------------- /.github/pull-request-template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Description** 4 | 5 | Changes proposed in this pull request: 6 | 7 | - 8 | 9 | **Related issue(s)** 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '15 15 * * 2' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'go' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 26 | # Learn more: 27 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v4 32 | 33 | # Initializes the CodeQL tools for scanning. 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v2 36 | with: 37 | languages: ${{ matrix.language }} 38 | # If you wish to specify custom queries, you can do so here or in a config file. 39 | # By default, queries listed here will override any specified in a config file. 40 | # Prefix the list here with "+" to use these queries and those in the config file. 41 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 42 | 43 | - name: Autobuild 44 | uses: github/codeql-action/autobuild@v2 45 | 46 | - name: Perform CodeQL Analysis 47 | uses: github/codeql-action/analyze@v2 48 | -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yml: -------------------------------------------------------------------------------- 1 | name: Pull request 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | env: 8 | GO111MODULE: on 9 | INSTALL_DEPS: true 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || 'branch' }} # scope to for the current workflow 17 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} # cancel only PR related jobs 18 | 19 | jobs: 20 | unit-test: 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | os: [ ubuntu-latest, macos-latest ] 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version-file: 'go.mod' 32 | cache: true 33 | - name: "Build and unit-test" 34 | run: make test-unit 35 | - name: "Hammer unit-test" 36 | run: make test-hammer 37 | code-quality-test: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Set up Go 42 | uses: actions/setup-go@v5 43 | with: 44 | go-version-file: 'go.mod' 45 | cache: true 46 | - name: "Code Quality Analysis" 47 | run: make test-lint 48 | integration-test: 49 | strategy: 50 | fail-fast: false 51 | matrix: 52 | os: [ ubuntu-latest, macos-latest, windows-latest ] 53 | runs-on: ${{ matrix.os }} 54 | steps: 55 | - name: Set git to use LF 56 | run: | 57 | git config --global core.autocrlf false 58 | git config --global core.eol lf 59 | - uses: actions/checkout@v4 60 | - name: Set up Go 61 | uses: actions/setup-go@v5 62 | with: 63 | go-version-file: 'go.mod' 64 | cache: true 65 | - if: matrix.os == 'windows-latest' 66 | run: echo "BINARY_EXT=.exe" >> $GITHUB_ENV 67 | - name: "Integration testing" 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.TOKEN_INTEGRATION_TESTS }} 70 | TOKEN_WITH_NO_SCOPES: ${{ secrets.TOKEN_WITH_NO_SCOPES }} 71 | APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} 72 | run: | 73 | echo "${{ env.BINARY_PATH }}" 74 | make test-integration 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | name: Run GoReleaser 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | packages: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version-file: 'go.mod' 24 | cache: true 25 | - name: Install upx 3.96 26 | run: | 27 | wget https://github.com/upx/upx/releases/download/v3.96/upx-3.96-amd64_linux.tar.xz 28 | 29 | # --strip-components=number - Strip given number of leading components from file names before extraction. 30 | # and extract only ./upx-3.96-amd64_linux/upx file 31 | tar --strip-components 1 -xf upx-3.96-amd64_linux.tar.xz upx-3.96-amd64_linux/upx 32 | 33 | mv ./upx /usr/local/bin/upx 34 | rm upx-3.96-amd64_linux.tar.xz 35 | upx -V 36 | - name: GHCR Login 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ghcr.io 40 | username: ${{ github.repository_owner }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | - name: Docker Hub Login 43 | uses: docker/login-action@v3 44 | with: 45 | username: mszostok 46 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 47 | - name: Run GoReleaser 48 | uses: goreleaser/goreleaser-action@v5 49 | with: 50 | distribution: goreleaser 51 | version: latest 52 | 53 | args: release --rm-dist 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GH_PAT_GORELEASER }} 56 | 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | scripts/ 2 | .idea 3 | codeowners-validator 4 | dist/ 5 | tmp/ 6 | bin/ 7 | coverage.txt 8 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | issues: 2 | exclude: 3 | # Check this issue for more info: https://github.com/kyoh86/scopelint/issues/4 4 | - Using the variable on range scope `tc` in function literal 5 | 6 | run: 7 | tests: true 8 | linters: 9 | disable-all: true 10 | enable: 11 | - gocritic 12 | - errcheck 13 | - gosimple 14 | - govet 15 | - ineffassign 16 | - staticcheck 17 | - typecheck 18 | - unused 19 | - revive 20 | - gofmt 21 | - misspell 22 | - gochecknoinits 23 | - unparam 24 | - exportloopref 25 | - gosec 26 | - goimports 27 | - whitespace 28 | - bodyclose 29 | - gocyclo 30 | 31 | fast: false 32 | 33 | 34 | linters-settings: 35 | gocritic: 36 | enabled-tags: 37 | - diagnostic 38 | - style 39 | - performance 40 | - experimental 41 | - opinionated 42 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: codeowners-validator 3 | env: 4 | - CGO_ENABLED=0 5 | goos: 6 | - linux 7 | - darwin 8 | - windows 9 | goarch: 10 | - "386" 11 | - amd64 12 | - arm64 13 | ldflags: 14 | - -s -w -X go.szostok.io/version.version={{.Version}} -X go.szostok.io/version.buildDate={{.Date}} 15 | # List of combinations of GOOS + GOARCH + GOARM to ignore. 16 | # Default is empty. 17 | ignore: 18 | - goos: windows # due to upx error: CantPackException: can't pack new-exe 19 | goarch: arm64 20 | hooks: 21 | # Install upx first, https://github.com/upx/upx/releases 22 | post: upx -9 "{{ .Path }}" 23 | 24 | archives: 25 | - replacements: 26 | darwin: Darwin 27 | linux: Linux 28 | windows: Windows 29 | 386: i386 30 | amd64: x86_64 31 | format_overrides: 32 | - goos: windows 33 | format: zip 34 | 35 | dockers: 36 | - dockerfile: Dockerfile 37 | ids: 38 | - codeowners-validator 39 | image_templates: 40 | - "ghcr.io/mszostok/codeowners-validator:stable" 41 | - "ghcr.io/mszostok/codeowners-validator:{{ .Tag }}" 42 | - "ghcr.io/mszostok/codeowners-validator:v{{ .Major }}.{{ .Minor }}" 43 | - "ghcr.io/mszostok/codeowners-validator:v{{ .Major }}" 44 | - "mszostok/codeowners-validator:latest" 45 | - "mszostok/codeowners-validator:{{ .Tag }}" 46 | - "mszostok/codeowners-validator:v{{ .Major }}.{{ .Minor }}" 47 | 48 | checksum: 49 | name_template: 'checksums.txt' 50 | 51 | snapshot: 52 | name_template: "{{ .Tag }}-next" 53 | 54 | changelog: 55 | use: github 56 | filters: 57 | exclude: 58 | - '^docs:' 59 | - '^test:' 60 | 61 | dist: bin 62 | 63 | release: 64 | # If set to true, will not auto-publish the release. 65 | # Default is false. 66 | draft: true 67 | # Repo in which the release will be created. 68 | # Default is extracted from the origin remote URL or empty if its private hosted. 69 | github: 70 | owner: mszostok 71 | name: codeowners-validator 72 | 73 | brews: 74 | - name: codeowners-validator 75 | homepage: https://github.com/mszostok/codeowners-validator 76 | description: Ensures the correctness of your CODEOWNERS file. 77 | license: "Apache License 2.0" 78 | tap: 79 | owner: mszostok 80 | name: homebrew-tap 81 | commit_author: 82 | name: Mateusz Szostok 83 | email: szostok.mateusz@gmail.com 84 | test: | 85 | system "#{bin}/codeowners-validator -v --short" 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 |

2 | logo 3 | Codeowners Validator - contributing 4 |

5 | 6 | 🎉🚀🤘 Thanks for your interest in the Codeowners Validator project! 🤘🚀🎉 7 | 8 | This document contains contribution guidelines for this repository. Read it before you start contributing. 9 | 10 | ## Contributing 11 | 12 | Before proposing or adding changes, check the [existing issues](https://github.com/mszostok/codeowners-validator/issues) and make sure the discussion/work has not already been started to avoid duplication. 13 | 14 | If you'd like to see a new feature implemented, use this [feature request template](https://github.com/mszostok/codeowners-validator/issues/new?assignees=&labels=&template=feature_request.md) to create an issue. 15 | 16 | Similarly, if you spot a bug, use this [bug report template](https://github.com/mszostok/codeowners-validator/issues/new?assignees=mszostok&labels=bug&template=bug_report.md) to let us know! 17 | 18 | ### Ready for action? Start developing! 19 | 20 | To start contributing, follow these steps: 21 | 22 | 1. Fork the `codeowners-validator` repository. 23 | 24 | 2. Clone the repository locally. 25 | 26 | > **TIP:** This project uses Go modules, so you can check it out locally wherever you want. It doesn't need to be checked out in `$GOPATH`. 27 | 28 | 3. Set the `codeowners-validator` repository as upstream: 29 | 30 | ```bash 31 | git remote add upstream git@github.com:mszostok/codeowners-validator.git 32 | ``` 33 | 34 | 4. Fetch all the remote branches for this repository: 35 | 36 | ```bash 37 | git fetch --all 38 | ``` 39 | 40 | 5. Set the `main` branch to point to upstream: 41 | 42 | ```bash 43 | git branch -u upstream/main main 44 | ``` 45 | 46 | You're all set! 🚀 Read the [development](./docs/development.md) document for further instructions. 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Get latest CA certs & git 2 | FROM alpine:3.19 as deps 3 | 4 | # hadolint ignore=DL3018 5 | RUN apk --no-cache add ca-certificates git 6 | 7 | FROM scratch 8 | 9 | LABEL org.opencontainers.image.source=https://github.com/mszostok/codeowners-validator 10 | 11 | COPY ./codeowners-validator /codeowners-validator 12 | 13 | COPY --from=deps /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 14 | COPY --from=deps /usr/bin/git /usr/bin/git 15 | COPY --from=deps /usr/bin/xargs /usr/bin/xargs 16 | COPY --from=deps /lib /lib 17 | COPY --from=deps /usr/lib /usr/lib 18 | 19 | ENTRYPOINT ["/codeowners-validator"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL = all 2 | 3 | ROOT_DIR:=$(dir $(abspath $(firstword $(MAKEFILE_LIST)))) 4 | 5 | # enable module support across all go commands. 6 | export GO111MODULE = on 7 | # enable consistent Go 1.12/1.13 GOPROXY behavior. 8 | export GOPROXY = https://proxy.golang.org 9 | 10 | all: build-race test-unit test-integration test-lint 11 | .PHONY: all 12 | 13 | # When running integration tests on windows machine 14 | # it cannot execute binary without extension. 15 | # It needs to be parametrized, so we can override it on CI. 16 | export BINARY_PATH = $(ROOT_DIR)/codeowners-validator$(BINARY_EXT) 17 | 18 | ############ 19 | # Building # 20 | ############ 21 | 22 | build: 23 | go build -o $(BINARY_PATH) . 24 | .PHONY: build 25 | 26 | build-race: 27 | go build -race -o $(BINARY_PATH) . 28 | .PHONY: build-race 29 | 30 | ########### 31 | # Testing # 32 | ########### 33 | 34 | test-unit: 35 | ./hack/run-test-unit.sh 36 | .PHONY: test-unit 37 | 38 | test-integration: build 39 | ./hack/run-test-integration.sh 40 | .PHONY: test-integration 41 | 42 | test-lint: 43 | ./hack/run-lint.sh 44 | .PHONY: test-lint 45 | 46 | test-hammer: 47 | go test -count=100 ./... 48 | .PHONY: test-hammer 49 | 50 | test-unit-cover-html: test-unit 51 | go tool cover -html=./coverage.txt 52 | .PHONY: cover-html 53 | 54 | ############### 55 | # Development # 56 | ############### 57 | 58 | fix-lint-issues: 59 | LINT_FORCE_FIX=true ./hack/run-lint.sh 60 | .PHONY: fix-lint 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | logo 5 |

Ensures the correctness of your CODEOWNERS file.

6 |

7 |
8 |
9 |
10 |
11 | 12 | ## Codeowners Validator 13 | Software License 14 | Go Report Card 15 | Twitter Follow 16 | 17 | The Codeowners Validator project validates the GitHub [CODEOWNERS](https://help.github.com/articles/about-code-owners/) file based on [specified checks](#checks). It supports public and private GitHub repositories and also GitHub Enterprise installations. 18 | 19 | ![usage](./docs/assets/usage.svg) 20 | 21 | ## Usage 22 | 23 | #### Docker 24 | 25 | ```bash 26 | export GH_TOKEN= 27 | docker run --rm -v $(pwd):/repo -w /repo \ 28 | -e REPOSITORY_PATH="." \ 29 | -e GITHUB_ACCESS_TOKEN="$GH_TOKEN" \ 30 | -e EXPERIMENTAL_CHECKS="notowned" \ 31 | -e OWNER_CHECKER_REPOSITORY="org-name/rep-name" \ 32 | mszostok/codeowners-validator:v0.7.4 33 | ``` 34 | 35 | #### Command line 36 | 37 | ```bash 38 | export GH_TOKEN= 39 | env REPOSITORY_PATH="." \ 40 | GITHUB_ACCESS_TOKEN="$GH_TOKEN" \ 41 | EXPERIMENTAL_CHECKS="notowned" \ 42 | OWNER_CHECKER_REPOSITORY="org-name/rep-name" \ 43 | codeowners-validator 44 | ``` 45 | 46 | #### GitHub Action 47 | 48 | ```yaml 49 | - uses: mszostok/codeowners-validator@v0.7.4 50 | with: 51 | checks: "files,owners,duppatterns,syntax" 52 | experimental_checks: "notowned,avoid-shadowing" 53 | # GitHub access token is required only if the `owners` check is enabled 54 | github_access_token: "${{ secrets.OWNERS_VALIDATOR_GITHUB_SECRET }}" 55 | ``` 56 | 57 | Check [this](./docs/gh-action.md) document for more information about GitHub Action. 58 | 59 | ---- 60 | 61 | Check the [Configuration](#configuration) section for more info on how to enable and configure given checks. 62 | 63 | ## Installation 64 | 65 | It's highly recommended to install a fixed version of `codeowners-validator`. Releases are available on the [releases page](https://github.com/mszostok/codeowners-validator/releases). 66 | 67 | ### macOS & Linux 68 | 69 | `codeowners-validator` is available via [Homebrew](https://brew.sh/index_pl). 70 | 71 | #### Homebrew 72 | 73 | | Install | Upgrade | 74 | |--------------------------------------------------|--------------------------------------------------| 75 | | `brew install mszostok/tap/codeowners-validator` | `brew upgrade mszostok/tap/codeowners-validator` | 76 | 77 | #### Install script 78 | 79 | ```bash 80 | # binary installed into ./bin/ 81 | curl -sfL https://raw.githubusercontent.com/mszostok/codeowners-validator/main/install.sh | sh -s v0.7.4 82 | 83 | # binary installed into $(go env GOPATH)/bin/codeowners-validator 84 | curl -sfL https://raw.githubusercontent.com/mszostok/codeowners-validator/main/install.sh | sh -s -- -b $(go env GOPATH)/bin v0.7.4 85 | 86 | # In alpine linux (as it does not come with curl by default) 87 | wget -O - -q https://raw.githubusercontent.com/mszostok/codeowners-validator/main/install.sh | sh -s v0.7.4 88 | 89 | # Print version. Add `--oshort` to print just the version number. 90 | codeowners-validator version 91 | ``` 92 | 93 | You can also download [latest version](https://github.com/mszostok/codeowners-validator/releases/latest) from release page manually. 94 | 95 | #### From Sources 96 | 97 | 98 | You can install `codeowners-validator` with `go install github.com/mszostok/codeowners-validator@v0.7.4`. 99 | 100 | > NOTE: please use Go 1.16 or greater. 101 | 102 | This will put `codeowners-validator` in `$(go env GOPATH)/bin`. 103 | 104 | ## Checks 105 | 106 | The following checks are enabled by default: 107 | 108 | | Name | Description | 109 | |-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 110 | | duppatterns | **[Duplicated Pattern Checker]**

Reports if CODEOWNERS file contain duplicated lines with the same file pattern. | 111 | | files | **[File Exist Checker]**

Reports if CODEOWNERS file contain lines with the file pattern that do not exist in a given repository. | 112 | | owners | **[Valid Owner Checker]**

Reports if CODEOWNERS file contain invalid owners definition. Allowed owner syntax: `@username`, `@org/team-name` or `user@example.com`
_source: https://help.github.com/articles/about-code-owners/#codeowners-syntax_.

**Checks:**
    1. Check if the owner's definition is valid (is either a GitHub user name, an organization team name or an email address).

    2. Check if a GitHub owner has a GitHub account

    3. Check if a GitHub owner is in a given organization

    4. Check if an organization team exists | 113 | | syntax | **[Valid Syntax Checker]**

Reports if CODEOWNERS file contain invalid syntax definition. It is imported as:
    "If any line in your CODEOWNERS file contains invalid syntax, the file will not be detected
    and will not be used to request reviews. Invalid syntax includes inline comments
    and user or team names that do not exist on GitHub."

_source: https://help.github.com/articles/about-code-owners/#codeowners-syntax_. | 114 | 115 | The experimental checks are disabled by default: 116 | 117 | | Name | Description | 118 | |-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 119 | | notowned | **[Not Owned File Checker]**

Reports if a given repository contain files that do not have specified owners in CODEOWNERS file. | 120 | | avoid-shadowing | **[Avoid Shadowing Checker]**

Reports if entries go from least specific to most specific. Otherwise, earlier entries are completely ignored.

For example:
     `# First entry`
     `/build/logs/ @octocat`
     `# Shadows`
     `* @s1`
     `/b*/logs @s5`
     `# OK`
     `/b*/other @o1`
     `/script/* @o2` | 121 | 122 | To enable experimental check set `EXPERIMENTAL_CHECKS=notowned` environment variable. 123 | 124 | Check the [Configuration](#configuration) section for more info on how to enable and configure given checks. 125 | 126 | ## Configuration 127 | 128 | Use the following environment variables to configure the application: 129 | 130 | | Name | Default | Description | 131 | |-----------------------------------------------|:------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 132 | | REPOSITORY_PATH * | | Path to your repository on your local machine. | 133 | | GITHUB_ACCESS_TOKEN | | GitHub access token. Instruction for creating a token can be found [here](./docs/gh-auth.md). If not provided, the owners validating functionality may not work properly. For example, you may reach the API calls quota or, if you are setting GitHub Enterprise base URL, an unauthorized error may occur. | 134 | | GITHUB_BASE_URL | `https://api.github.com/` | GitHub base URL for API requests. Defaults to the public GitHub API but can be set to a domain endpoint to use with GitHub Enterprise. | 135 | | GITHUB_UPLOAD_URL | `https://uploads.github.com/` | GitHub upload URL for uploading files.

It is taken into account only when `GITHUB_BASE_URL` is also set. If only `GITHUB_BASE_URL` is provided, this parameter defaults to the `GITHUB_BASE_URL` value. | 136 | | GITHUB_APP_ID | | Github App ID for authentication. This replaces the `GITHUB_ACCESS_TOKEN`. Instruction for creating a Github App can be found [here](./docs/gh-auth.md) | 137 | | GITHUB_APP_INSTALLATION_ID | | Github App Installation ID. Required when `GITHUB_APP_ID` is set. | 138 | | GITHUB_APP_PRIVATE_KEY | | Github App private key in PEM format. Required when `GITHUB_APP_ID` is set. | 139 | | CHECKS | | List of checks to be executed. By default, all checks are executed. Possible values: `files`,`owners`,`duppatterns`,`syntax`. | 140 | | EXPERIMENTAL_CHECKS | | The comma-separated list of experimental checks that should be executed. By default, all experimental checks are turned off. Possible values: `notowned`. | 141 | | CHECK_FAILURE_LEVEL | `warning` | Defines the level on which the application should treat check issues as failures. Defaults to `warning`, which treats both errors and warnings as failures, and exits with error code 3. Possible values are `error` and `warning`. | 142 | | OWNER_CHECKER_REPOSITORY * | | The owner and repository name separated by slash. For example, gh-codeowners/codeowners-samples. Used to check if GitHub owner is in the given organization. | 143 | | OWNER_CHECKER_IGNORED_OWNERS | `@ghost` | The comma-separated list of owners that should not be validated. Example: `"@owner1,@owner2,@org/team1,example@email.com"`. | 144 | | OWNER_CHECKER_ALLOW_UNOWNED_PATTERNS | `true` | Specifies whether CODEOWNERS may have unowned files. For example:

`/infra/oncall-rotator/ @sre-team`
`/infra/oncall-rotator/oncall-config.yml`

The `/infra/oncall-rotator/oncall-config.yml` file is not owned by anyone. | 145 | | OWNER_CHECKER_OWNERS_MUST_BE_TEAMS | `false` | Specifies whether only teams are allowed as owners of files. | 146 | | NOT_OWNED_CHECKER_SKIP_PATTERNS | | The comma-separated list of patterns that should be ignored by `not-owned-checker`. For example, you can specify `*` and as a result, the `*` pattern from the **CODEOWNERS** file will be ignored and files owned by this pattern will be reported as unowned unless a later specific pattern will match that path. It's useful because often we have default owners entry at the begging of the CODOEWNERS file, e.g. `* @global-owner1 @global-owner2` | 147 | | NOT_OWNED_CHECKER_SUBDIRECTORIES | | The comma-separated list of subdirectories to check in `not-owned-checker`. When specified, only files in the listed subdirectories will be checked if they do not have specified owners in CODEOWNERS. | 148 | | NOT_OWNED_CHECKER_TRUST_WORKSPACE | `false` | Specifies whether the repository path should be marked as safe. See: https://github.com/actions/checkout/issues/766. | 149 | 150 | * - Required 151 | 152 | #### Exit status codes 153 | 154 | Application exits with different status codes which allow you to easily distinguish between error categories. 155 | 156 | | Code | Description | 157 | |:-----:|:------------------------------------------------------------------------------------------| 158 | | **1** | The application startup failed due to the wrong configuration or internal error. | 159 | | **2** | The application was closed because the OS sends a termination signal (SIGINT or SIGTERM). | 160 | | **3** | The CODEOWNERS validation failed - executed checks found some issues. | 161 | 162 | ## Contributing 163 | 164 | Contributions are greatly appreciated! The project follows the typical GitHub pull request model. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details. 165 | 166 | ## Roadmap 167 | 168 | The [codeowners-validator roadmap uses GitHub milestones](https://github.com/mszostok/codeowners-validator/milestone/1) to track the progress of the project. 169 | 170 | They are sorted with priority. First are most important. 171 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Please report (suspected) security vulnerabilities to **[szostok.mateusz@gmail.com](mailto:szostok.mateusz@gmail.com)**. You will receive a response within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity. 4 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "GitHub CODEOWNERS Validator" 2 | description: "GitHub action to ensure the correctness of your CODEOWNERS file." 3 | author: "szostok.mateusz@gmail.com" 4 | 5 | inputs: 6 | github_access_token: 7 | description: "The GitHub access token. Instruction for creating a token can be found here: https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token. If not provided then validating owners functionality could not work properly, e.g. you can reach the API calls quota or if you are setting GitHub Enterprise base URL then an unauthorized error can occur." 8 | required: false 9 | 10 | github_app_id: 11 | description: "Github App ID for authentication. This replaces the GITHUB_ACCESS_TOKEN. Instruction for creating a Github App can be found here: https://github.com/mszostok/codeowners-validator/blob/main/docs/gh-token.md" 12 | required: false 13 | 14 | github_app_installation_id: 15 | description: "Github App Installation ID. Required when GITHUB_APP_ID is set." 16 | required: false 17 | 18 | github_app_private_key: 19 | description: "Github App private key in PEM format. Required when GITHUB_APP_ID is set." 20 | required: false 21 | 22 | github_base_url: 23 | description: "The GitHub base URL for API requests. Defaults to the public GitHub API, but can be set to a domain endpoint to use with GitHub Enterprise. Default: https://api.github.com/" 24 | required: false 25 | 26 | github_upload_url: 27 | description: "The GitHub upload URL for uploading files. It is taken into account only when the GITHUB_BASE_URL is also set. If only the GITHUB_BASE_URL is provided then this parameter defaults to the GITHUB_BASE_URL value. Default: https://uploads.github.com/" 28 | required: false 29 | 30 | experimental_checks: 31 | description: "The comma-separated list of experimental checks that should be executed. By default, all experimental checks are turned off. Possible values: notowned." 32 | default: "" 33 | required: false 34 | 35 | checks: 36 | description: "The list of checks that will be executed. By default, all checks are executed. Possible values: files,owners,duppatterns,syntax" 37 | required: false 38 | default: "" 39 | 40 | repository_path: 41 | description: "The repository path in which CODEOWNERS file should be validated." 42 | required: false 43 | default: "." 44 | 45 | check_failure_level: 46 | description: "Defines the level on which the application should treat check issues as failures. Defaults to warning, which treats both errors and warnings as failures, and exits with error code 3. Possible values are error and warning. Default: warning" 47 | required: false 48 | 49 | not_owned_checker_skip_patterns: 50 | description: "The comma-separated list of patterns that should be ignored by not-owned-checker. For example, you can specify * and as a result, the * pattern from the CODEOWNERS file will be ignored and files owned by this pattern will be reported as unowned unless a later specific pattern will match that path. It's useful because often we have default owners entry at the begging of the CODOEWNERS file, e.g. * @global-owner1 @global-owner2" 51 | required: false 52 | 53 | owner_checker_repository: 54 | description: "The owner and repository name. For example, gh-codeowners/codeowners-samples. Used to check if GitHub team is in the given organization and has permission to the given repository." 55 | required: false 56 | default: "${{ github.repository }}" 57 | 58 | owner_checker_ignored_owners: 59 | description: "The comma-separated list of owners that should not be validated. Example: @owner1,@owner2,@org/team1,example@email.com." 60 | required: false 61 | 62 | owner_checker_allow_unowned_patterns: 63 | description: "Specifies whether CODEOWNERS may have unowned files. For example, `/infra/oncall-rotator/oncall-config.yml` doesn't have owner and this is not reported." 64 | default: "true" 65 | required: false 66 | 67 | owner_checker_owners_must_be_teams: 68 | description: "Specifies whether only teams are allowed as owners of files." 69 | default: "false" 70 | required: false 71 | 72 | not_owned_checker_subdirectories: 73 | description: "Only check listed subdirectories for CODEOWNERS ownership that don't have owners." 74 | required: false 75 | 76 | not_owned_checker_trust_workspace: 77 | description: "Specifies whether the repository path should be marked as safe. See: https://github.com/actions/checkout/issues/766" 78 | required: false 79 | default: "true" 80 | 81 | runs: 82 | using: 'docker' 83 | image: 'docker://ghcr.io/mszostok/codeowners-validator:v0.7.4' 84 | env: 85 | ENVS_PREFIX: "INPUT" 86 | 87 | branding: 88 | icon: "shield" 89 | color: "gray-dark" 90 | -------------------------------------------------------------------------------- /docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Codeowners Validator" 3 | } 4 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |

2 | logo 3 | Codeowners Validator documentation 4 |

5 | 6 | Welcome to the Codeowners Validator documentation. 7 | 8 | + [Development](./development.md) 9 | + [GitHub Action](./gh-action.md) 10 | + [GitHub Auth](./gh-auth.md) 11 | + [Release](./release.md) 12 | -------------------------------------------------------------------------------- /docs/assets/action-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mszostok/codeowners-validator/f3651e3810802a37bd965e6a9a7210728179d076/docs/assets/action-output.png -------------------------------------------------------------------------------- /docs/assets/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mszostok/codeowners-validator/f3651e3810802a37bd965e6a9a7210728179d076/docs/assets/logo-small.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mszostok/codeowners-validator/f3651e3810802a37bd965e6a9a7210728179d076/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/token-private.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mszostok/codeowners-validator/f3651e3810802a37bd965e6a9a7210728179d076/docs/assets/token-private.png -------------------------------------------------------------------------------- /docs/assets/token-public.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mszostok/codeowners-validator/f3651e3810802a37bd965e6a9a7210728179d076/docs/assets/token-public.png -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | [← back to docs](./README.md) 2 | 3 | # Development 4 | 5 | This document contains development instructions. Read it to learn how to develop this project. 6 | 7 | # Table of Contents 8 | 9 | 10 | 11 | - [Prerequisites](#prerequisites) 12 | - [Dependency management](#dependency-management) 13 | - [Testing](#testing) 14 | * [Unit tests](#unit-tests) 15 | * [Lint tests](#lint-tests) 16 | * [Integration tests](#integration-tests) 17 | - [Build a binary](#build-a-binary) 18 | 19 | 20 | 21 | ## Prerequisites 22 | 23 | * [Go](https://golang.org/dl/) 1.15 or higher 24 | * [Docker](https://www.docker.com/) 25 | * Make 26 | 27 | Helper scripts may introduce additional dependencies. However, all helper scripts support the `INSTALL_DEPS` environment variable flag. 28 | By default, this flag is set to `false`. This way, the scripts will try to use the tools installed on your local machine. This helps speed up the development process. 29 | If you do not want to install any additional tools, or you want to ensure reproducible script 30 | results, export `INSTALL_DEPS=true`. This way, the proper tool version will be automatically installed and used. 31 | 32 | ## Dependency management 33 | 34 | This project uses `go modules` for dependency management. To install all required dependencies, use the following command: 35 | 36 | ```bash 37 | go mod download 38 | ``` 39 | 40 | ## Testing 41 | 42 | ### Unit tests 43 | 44 | To run all unit tests, execute: 45 | 46 | ```bash 47 | make test-unit 48 | ``` 49 | 50 | To generate the unit test coverage HTML report, execute: 51 | 52 | ```bash 53 | make test-unit-cover-html 54 | ``` 55 | 56 | > **NOTE:** The generated report opens automatically in your default browser. 57 | 58 | ### Lint tests 59 | 60 | To check your code for errors, such as typos, wrong formatting, security issues, etc., execute: 61 | 62 | ```bash 63 | make test-lint 64 | ``` 65 | 66 | To automatically fix detected lint issues, execute: 67 | 68 | ```bash 69 | make fix-lint-issues 70 | ``` 71 | 72 | ### Integration tests 73 | 74 | This project supports the integration tests that are defined in the [tests](../tests) package. The tests are executed against [`gh-codeowners/codeowners-samples`](https://github.com/gh-codeowners/codeowners-samples). 75 | 76 | > **CAUTION:** Currently, running the integration tests both on external PRs and locally by external contributors is not supported, as the teams used for testing are visible only to the organization members. 77 | > At the moment, the `codeowners-validator` repository owner is responsible for running these tests. 78 | 79 | ## Build a binary 80 | 81 | To generate a binary for this project, execute: 82 | ```bash 83 | make build 84 | ``` 85 | 86 | This command generates a binary named `codeowners-validator` in the root directory. 87 | 88 | [↑ Back to top](#table-of-contents) 89 | -------------------------------------------------------------------------------- /docs/gh-action.md: -------------------------------------------------------------------------------- 1 | [← back to docs](./README.md) 2 | 3 |

4 |

GitHub Action for CODEOWNERS Validator

5 |

Ensures the correctness of your CODEOWNERS file.

6 |

7 | Software License 8 |

9 |

10 | 11 | ## 12 | The [Codeowners Validator](https://github.com/mszostok/codeowners-validator) is available as a GitHub Action. 13 | 14 |

15 | demo 16 |

17 | 18 | 19 | ## Usage 20 | 21 | Create a workflow (eg: `.github/workflows/sanity.yml` see [Creating a Workflow file](https://help.github.com/en/articles/configuring-a-workflow#creating-a-workflow-file)) 22 | 23 | ```yaml 24 | name: "Codeowners Validator" 25 | 26 | on: 27 | schedule: 28 | # Runs at 08:00 UTC every day 29 | - cron: '0 8 * * *' 30 | 31 | jobs: 32 | sanity: 33 | runs-on: ubuntu-latest 34 | steps: 35 | # Checks-out your repository, which is validated in the next step 36 | - uses: actions/checkout@v2 37 | - name: GitHub CODEOWNERS Validator 38 | uses: mszostok/codeowners-validator@v0.7.4 39 | # input parameters 40 | with: 41 | # ==== GitHub Auth ==== 42 | 43 | ## ==== PAT ==== 44 | # GitHub access token is required only if the `owners` check is enabled 45 | github_access_token: "${{ secrets.OWNERS_VALIDATOR_GITHUB_SECRET }}" 46 | 47 | ## ==== App ==== 48 | # GitHub App ID for authentication. This replaces the github_access_token. 49 | github_app_id: ${{ secrets.APP_ID }} 50 | 51 | # GitHub App Installation ID. Required when github_app_id is set. 52 | github_app_installation_id: ${{ secrets.APP_INSTALLATION_ID }} 53 | 54 | # GitHub App private key in PEM format. Required when github_app_id is set. 55 | github_app_private_key: ${{ secrets.APP_PRIVATE_KEY }} 56 | 57 | # ==== GitHub Auth ==== 58 | 59 | # "The list of checks that will be executed. By default, all checks are executed. Possible values: files,owners,duppatterns,syntax" 60 | checks: "files,owners,duppatterns,syntax" 61 | 62 | # "The comma-separated list of experimental checks that should be executed. By default, all experimental checks are turned off. Possible values: notowned,avoid-shadowing" 63 | experimental_checks: "notowned,avoid-shadowing" 64 | 65 | # The GitHub base URL for API requests. Defaults to the public GitHub API, but can be set to a domain endpoint to use with GitHub Enterprise. 66 | github_base_url: "https://api.github.com/" 67 | 68 | # The GitHub upload URL for uploading files. It is taken into account only when the GITHUB_BASE_URL is also set. If only the GITHUB_BASE_URL is provided then this parameter defaults to the GITHUB_BASE_URL value. 69 | github_upload_url: "https://uploads.github.com/" 70 | 71 | # The repository path in which CODEOWNERS file should be validated." 72 | repository_path: "." 73 | 74 | # Defines the level on which the application should treat check issues as failures. Defaults to warning, which treats both errors and warnings as failures, and exits with error code 3. Possible values are error and warning. Default: warning" 75 | check_failure_level: "warning" 76 | 77 | # The comma-separated list of patterns that should be ignored by not-owned-checker. For example, you can specify * and as a result, the * pattern from the CODEOWNERS file will be ignored and files owned by this pattern will be reported as unowned unless a later specific pattern will match that path. It's useful because often we have default owners entry at the begging of the CODOEWNERS file, e.g. * @global-owner1 @global-owner2" 78 | not_owned_checker_skip_patterns: "" 79 | 80 | # The owner and repository name. For example, gh-codeowners/codeowners-samples. Used to check if GitHub team is in the given organization and has permission to the given repository." 81 | owner_checker_repository: "${{ github.repository }}" 82 | 83 | # The comma-separated list of owners that should not be validated. Example: @owner1,@owner2,@org/team1,example@email.com." 84 | owner_checker_ignored_owners: "@ghost" 85 | 86 | # Specifies whether CODEOWNERS may have unowned files. For example, `/infra/oncall-rotator/oncall-config.yml` doesn't have owner and this is not reported. 87 | owner_checker_allow_unowned_patterns: "true" 88 | 89 | # Specifies whether only teams are allowed as owners of files. 90 | owner_checker_owners_must_be_teams: "false" 91 | 92 | # Only check listed subdirectories for CODEOWNERS ownership that don't have owners. 93 | not_owned_checker_subdirectories: "" 94 | ``` 95 | 96 | The best is to run this as a cron job and not only if you applying changes to CODEOWNERS file itself, e.g. the CODEOWNERS file can be invalidate when you removing someone from the organization. 97 | 98 | > **Note** 99 | > 100 | > To execute `owners` check you need to create a [GitHub token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token) and store it as a secret in your repository, see ["Creating and storing encrypted secrets."](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets). Token requires only read-only scope for your repository. 101 | 102 | 103 | 104 | ## Configuration 105 | 106 | For the GitHub Action, use the configuration described in the main README under the [Configuration](../README.md#configuration) section but **specify it as the [Action input parameters](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepswith) instead of environment variables**. See the [Usage](#usage) section for the full syntax. 107 | 108 | If you want to use environment variables anyway, you must add the `INPUT_` prefix to each environment variable. For example, `OWNER_CHECKER_IGNORED_OWNERS` becomes `INPUT_OWNER_CHECKER_IGNORED_OWNERS`. 109 | -------------------------------------------------------------------------------- /docs/gh-auth.md: -------------------------------------------------------------------------------- 1 | [← back to docs](./README.md) 2 | 3 | # GitHub tokens 4 | 5 | The [valid_owner.go](./../internal/check/valid_owner.go) check requires the GitHub token for the following reasons: 6 | 7 | 1. Information about organization teams and their repositories is not publicly available. 8 | 2. If you set GitHub Enterprise base URL, an unauthorized error may occur. 9 | 3. For unauthenticated requests, the rate limit allows for up to 60 requests per hour. Unauthenticated requests are associated with the originating IP address. In a big organization where you have a lot of calls between your infrastructure server and the GitHub site, it is easy to exceed that quota. 10 | 11 | The Codeowners Validator source code is available on GitHub. You can always perform a security audit against its code base and build your own version from the source code if your organization is stricter about the software run in its infrastructure. 12 | 13 | You can either use a [personal access token](#github-personal-access-token) or a [GitHub App](#github-app). 14 | 15 | ## GitHub personal access token 16 | 17 | Instructions for creating a token can be found [here](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token). The minimal scope required for the token is **read-only**, but the definition of this scope differs between public and private repositories. 18 | 19 | #### Public repositories 20 | 21 | For public repositories, select `public_repo` and `read:org`: 22 | 23 | ![token-public.png](./assets/token-public.png) 24 | 25 | #### Private repositories 26 | 27 | For private repositories, select `repo` and `read:org`: 28 | 29 | ![token-public.png](./assets/token-private.png) 30 | 31 | 32 | ## GitHub App 33 | 34 | Here are the steps to create a GitHub App and use it for this tool: 35 | 36 | 1. [Create a GitHub App](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app). 37 | > **Note** 38 | > Your app does not need a callback or a webhook URL. 39 | 2. Add a read-only permission to the "Members" item of organization permissions. 40 | 3. [Install the app in your organization](https://docs.github.com/en/developers/apps/managing-github-apps/installing-github-apps). 41 | 4. Done! To authenticate with your app, you need: 42 | 43 | | Name | Description | 44 | |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 45 | | GitHub App Private Key | PEM-format key generated when the app is installed. If you lost it, you can regenerate it ([docs](https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#generating-a-private-key)). | 46 | | GitHub App ID | Found in the app's "About" page (Organization settings -> Developer settings -> Edit button on your app). | 47 | | GitHub App Installation ID | Found in the URL your organization's app install page (Organization settings -> Github Apps -> Configure button on your app). It's the last number in the URL, ex: `https://github.com/organizations/{my-org}/settings/installations/1234567890`. | 48 | 49 | 6. Depends on the usage you need to: 50 | 51 | 1. **CLI:** Export them as environment variable: 52 | - `GITHUB_APP_INSTALLATION_ID` 53 | - `GITHUB_APP_ID` 54 | - `GITHUB_APP_PRIVATE_KEY` 55 | 56 | 2. [**GitHub Action:**](gh-action.md) Define them as GitHub secrets and use under the `with` property: 57 | 58 | ```yaml 59 | - name: GitHub CODEOWNERS Validator 60 | uses: mszostok/codeowners-validator@v0.7.4 61 | with: 62 | # ... 63 | github_app_id: ${{ secrets.APP_ID }} 64 | github_app_installation_id: ${{ secrets.APP_INSTALLATION_ID }} 65 | github_app_private_key: ${{ secrets.APP_PRIVATE_KEY }} 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/investigation/file_exists_checker/file_matcher_libs_bench_test.go: -------------------------------------------------------------------------------- 1 | // Always record the result of func execution to prevent 2 | // the compiler eliminating the function call. 3 | // Always store the result to a package level variable 4 | // so the compiler cannot eliminate the Benchmark itself. 5 | package file_exists_checker 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "os" 11 | "path" 12 | "testing" 13 | 14 | "github.com/bmatcuk/doublestar/v2" 15 | "github.com/mattn/go-zglob" 16 | "github.com/yargevad/filepathx" 17 | ) 18 | 19 | var pattern string 20 | func init() { 21 | curDir, err := os.Getwd() 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | pattern = path.Join(curDir, "..", "..", "**", "*.md") 26 | fmt.Println(pattern) 27 | } 28 | 29 | var pathx []string 30 | 31 | func BenchmarkPathx(b *testing.B) { 32 | var r []string 33 | for n := 0; n < b.N; n++ { 34 | r, _ = filepathx.Glob(pattern) 35 | } 36 | pathx = r 37 | } 38 | 39 | var zGlob []string 40 | 41 | func BenchmarkZGlob(b *testing.B) { 42 | var r []string 43 | for n := 0; n < b.N; n++ { 44 | r, _ = zglob.Glob(pattern) 45 | } 46 | zGlob = r 47 | } 48 | 49 | var double []string 50 | 51 | func BenchmarkDoublestar(b *testing.B) { 52 | var r []string 53 | for n := 0; n < b.N; n++ { 54 | r, _ = doublestar.Glob(pattern) 55 | } 56 | double = r 57 | } 58 | -------------------------------------------------------------------------------- /docs/investigation/file_exists_checker/glob.md: -------------------------------------------------------------------------------- 1 | ## File exits checker 2 | 3 | This document describes investigation about [`file exists`](../../../internal/check/file_exists.go) checker which needs to deal with the gitignore pattern syntax 4 | 5 | ### Problem 6 | 7 | A [CODEOWNERS](https://docs.github.com/en/free-pro-team@latest/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax) file uses a pattern that follows the same rules used in [gitignore](https://git-scm.com/docs/gitignore#_pattern_format) files. 8 | The gitignore files support two consecutive asterisks ("**") in patterns that match against the full path name. Unfortunately the core Go library `filepath.Glob` does not support [`**`](https://github.com/golang/go/issues/11862) at all. 9 | 10 | This caused that for some patterns the [`file exists`](../../../internal/check/file_exists.go) checker didn't work properly, see [issue#22](https://github.com/mszostok/codeowners-validator/issues/22). 11 | 12 | Additionally, we need to support a single asterisk at the beginning of the pattern. For example, `*.js` should check for all JS files in the whole git repository. To achieve that we need to detect that and change from `*.js` to `**/*.js`. 13 | 14 | ```go 15 | pattern := "*.js" 16 | if len(pattern) >= 2 && pattern[:1] == "*" && pattern[1:2] != "*" { 17 | pattern = "**/" + pattern 18 | } 19 | ``` 20 | 21 | ### Investigation 22 | 23 | Instead of creating a dedicated solution, I decided to search for a custom library that's supporting two consecutive asterisks. 24 | There are a few libraries in open-source that can be used for that purpose. I selected three: 25 | - https://github.com/bmatcuk/doublestar/v2 26 | - https://github.com/mattn/go-zglob 27 | - https://github.com/yargevad/filepathx 28 | 29 | I've tested all libraries and all of them were supporting `**` pattern properly. As a final criterion, I created benchmark tests. 30 | 31 | #### Benchmarks 32 | 33 | Run benchmarks with 1 CPU for 5 seconds: 34 | 35 | ```bash 36 | go test -bench=. -benchmem -cpu 1 -benchtime 5s ./file_matcher_libs_bench_test.go 37 | 38 | goos: darwin 39 | goarch: amd64 40 | BenchmarkPathx 79 72276938 ns/op 7297258 B/op 40808 allocs/op 41 | BenchmarkZGlob 126 47206545 ns/op 840973 B/op 10550 allocs/op 42 | BenchmarkDoublestar 157 38041578 ns/op 3521379 B/op 22150 allocs/op 43 | ``` 44 | 45 | Run benchmarks with 12 CPU for 5 seconds: 46 | ```bash 47 | go test -bench=. -benchmem -cpu 12 -benchtime 5s ./file_matcher_libs_bench_test.go 48 | 49 | goos: darwin 50 | goarch: amd64 51 | BenchmarkPathx-12 78 73096386 ns/op 7297114 B/op 40807 allocs/op 52 | BenchmarkZGlob-12 637 9234632 ns/op 914239 B/op 10564 allocs/op 53 | BenchmarkDoublestar-12 151 38372922 ns/op 3522899 B/op 22151 allocs/op 54 | ``` 55 | 56 | #### Summary 57 | 58 | With the 1 CPU , the `doublestar` library has the shortest time, but the allocated memory is higher than the `z-glob` library. 59 | With the 12 CPU, the `z-glob` is a winner bot in time and memory allocation. The worst one in each test was the `filepathx` library. 60 | 61 | > **NOTE:** The `z-glob` library has an issue with error handling. I've provided PR for fixing that problem: https://github.com/mattn/go-zglob/pull/37. -------------------------------------------------------------------------------- /docs/investigation/file_exists_checker/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mszostok/codeowners-validator/docs/investigation/file_exists_checker 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/bmatcuk/doublestar/v2 v2.0.1 7 | github.com/mattn/go-zglob v0.0.4-0.20201017022353-70beb5203ba6 8 | github.com/yargevad/filepathx v0.0.0-20161019152617-907099cb5a62 9 | ) 10 | -------------------------------------------------------------------------------- /docs/investigation/file_exists_checker/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bmatcuk/doublestar/v2 v2.0.1 h1:EFT91DmIMRcrUEcYUW7AqSAwKvNzP5+CoDmNVBbcQOU= 2 | github.com/bmatcuk/doublestar/v2 v2.0.1/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= 3 | github.com/mattn/go-zglob v0.0.4-0.20201017022353-70beb5203ba6 h1:nw6OKTHiQIVOSaT4xJ5STrLfUFs3xlU5dc6H4pT5bVQ= 4 | github.com/mattn/go-zglob v0.0.4-0.20201017022353-70beb5203ba6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= 5 | github.com/yargevad/filepathx v0.0.0-20161019152617-907099cb5a62 h1:pZlTNPEY1N9n4Frw+wiRy9goxBru/H5KaBxJ4bFt89w= 6 | github.com/yargevad/filepathx v0.0.0-20161019152617-907099cb5a62/go.mod h1:VtdjfTSVslSOB39qCxkH9K3m2qUauaJk/6y+pNkvCQY= 7 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | [← back to docs](./README.md) 2 | 3 | # Release process 4 | 5 | The release of the codeowners-validator tool is performed by the [GoReleaser](https://github.com/goreleaser/goreleaser) which builds Go binaries for several platforms and then creates a GitHub release. 6 | 7 | **Process** 8 | 9 | 1. Export GITHUB_TOKEN=`YOUR_GH_TOKEN` 10 | 11 | 2. Tag commit 12 | ```bash 13 | git tag -a v0.1.0 -m "First release" 14 | ``` 15 | 16 | 3. Push tag 17 | ``` 18 | git push origin v0.1.0 19 | ``` 20 | 21 | 4. Locally from the root of the repository, run `goreleaser`. 22 | >**NOTE:** Currently, releases are made with goreleaser in version `0.104.0, commit 7c4352147b6d9636f13d2fc633cfab05d82d929c, built at 2019-03-20T02:18:40Z` 23 | 24 | 5. Recheck release generated on GitHub. 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.szostok.io/codeowners-validator 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/bradleyfalzon/ghinstallation/v2 v2.9.0 7 | github.com/dustin/go-humanize v1.0.1 8 | github.com/fatih/color v1.16.0 9 | github.com/google/go-github/v41 v41.0.0 10 | github.com/hashicorp/go-multierror v1.1.1 11 | github.com/mattn/go-zglob v0.0.4 12 | github.com/pkg/errors v0.9.1 13 | github.com/sebdah/goldie/v2 v2.5.3 14 | github.com/sergi/go-diff v1.3.1 // indirect 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/spf13/afero v1.11.0 17 | github.com/spf13/pflag v1.0.5 // indirect 18 | github.com/stretchr/testify v1.8.4 19 | github.com/vrischmann/envconfig v1.3.0 20 | go.szostok.io/version v1.2.0 21 | golang.org/x/crypto v0.19.0 // indirect 22 | golang.org/x/oauth2 v0.17.0 23 | golang.org/x/sys v0.17.0 // indirect 24 | gopkg.in/pipe.v2 v2.0.0-20140414041502-3c2ca4d52544 25 | gotest.tools v2.2.0+incompatible 26 | ) 27 | 28 | require ( 29 | github.com/go-git/go-git/v5 v5.11.0 30 | github.com/spf13/cobra v1.8.0 31 | ) 32 | 33 | require ( 34 | dario.cat/mergo v1.0.0 // indirect 35 | github.com/Masterminds/goutils v1.1.1 // indirect 36 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 37 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect 38 | github.com/Microsoft/go-winio v0.6.1 // indirect 39 | github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect 40 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect 41 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 42 | github.com/cloudflare/circl v1.3.7 // indirect 43 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/emirpasic/gods v1.18.1 // indirect 46 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 47 | github.com/go-git/go-billy/v5 v5.5.0 // indirect 48 | github.com/goccy/go-yaml v1.11.3 // indirect 49 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 50 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 51 | github.com/golang/protobuf v1.5.3 // indirect 52 | github.com/google/go-cmp v0.6.0 // indirect 53 | github.com/google/go-github/v57 v57.0.0 // indirect 54 | github.com/google/go-querystring v1.1.0 // indirect 55 | github.com/google/uuid v1.6.0 // indirect 56 | github.com/hashicorp/errwrap v1.1.0 // indirect 57 | github.com/hashicorp/go-version v1.6.0 // indirect 58 | github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect 59 | github.com/huandu/xstrings v1.4.0 // indirect 60 | github.com/imdario/mergo v0.3.16 // indirect 61 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 62 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 63 | github.com/kevinburke/ssh_config v1.2.0 // indirect 64 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 65 | github.com/mattn/go-colorable v0.1.13 // indirect 66 | github.com/mattn/go-isatty v0.0.20 // indirect 67 | github.com/mattn/go-runewidth v0.0.15 // indirect 68 | github.com/mitchellh/copystructure v1.2.0 // indirect 69 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 70 | github.com/muesli/termenv v0.15.2 // indirect 71 | github.com/pjbgf/sha1cd v0.3.0 // indirect 72 | github.com/pmezard/go-difflib v1.0.0 // indirect 73 | github.com/rivo/uniseg v0.4.7 // indirect 74 | github.com/shopspring/decimal v1.3.1 // indirect 75 | github.com/skeema/knownhosts v1.2.1 // indirect 76 | github.com/spf13/cast v1.6.0 // indirect 77 | github.com/xanzy/ssh-agent v0.3.3 // indirect 78 | golang.org/x/mod v0.12.0 // indirect 79 | golang.org/x/net v0.21.0 // indirect 80 | golang.org/x/text v0.14.0 // indirect 81 | golang.org/x/tools v0.13.0 // indirect 82 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 83 | google.golang.org/appengine v1.6.8 // indirect 84 | google.golang.org/protobuf v1.32.0 // indirect 85 | gopkg.in/warnings.v0 v0.1.2 // indirect 86 | gopkg.in/yaml.v3 v3.0.1 // indirect 87 | ) 88 | -------------------------------------------------------------------------------- /hack/README.md: -------------------------------------------------------------------------------- 1 | # Hack directory 2 | 3 | This package contains various scripts that are used by Codeowners Validator developers. 4 | 5 | ## Purpose 6 | 7 | This directory contains tools, such as Go fmt, Go lint, and Go vet, that help to maintain the source code compliant to Go best coding practices. It also includes utility scripts that generate code, and scripts executed on CI pipelines. 8 | -------------------------------------------------------------------------------- /hack/compress.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Inspired by https://liam.sh/post/makefiles-for-go-projects 3 | 4 | # standard bash error handling 5 | set -o nounset # treat unset variables as an error and exit immediately. 6 | set -o errexit # exit immediately when a command fails. 7 | set -E # needs to be set if we want the ERR trap 8 | 9 | CURRENT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 10 | ROOT_PATH=$(cd "${CURRENT_DIR}/.." && pwd) 11 | readonly CURRENT_DIR 12 | readonly ROOT_PATH 13 | 14 | # shellcheck source=./hack/lib/utilities.sh 15 | source "${CURRENT_DIR}/lib/utilities.sh" || { 16 | echo 'Cannot load CI utilities.' 17 | exit 1 18 | } 19 | 20 | function main() { 21 | # This will find all files (not symlinks) with the executable bit set: 22 | # https://apple.stackexchange.com/a/116371 23 | binariesToCompress=$(find "${ROOT_PATH}/dist" -perm +111 -type f) 24 | 25 | shout "Staring compression for: \n$binariesToCompress" 26 | 27 | command -v upx >/dev/null || { 28 | echo 'UPX binary not found, skipping compression.' 29 | exit 1 30 | } 31 | 32 | # I just do not like playing with xargs ¯\_(ツ)_/¯ 33 | for i in $binariesToCompress; do 34 | upx --brute "$i" 35 | done 36 | } 37 | 38 | main 39 | -------------------------------------------------------------------------------- /hack/lib/utilities.sh: -------------------------------------------------------------------------------- 1 | # 2 | # Library of useful utilities for CI purposes. 3 | # 4 | 5 | readonly RED='\033[0;31m' 6 | readonly GREEN='\033[0;32m' 7 | readonly INVERTED='\033[7m' 8 | readonly NC='\033[0m' # No Color 9 | 10 | readonly LIB_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 11 | 12 | # Prints first argument as header. Additionally prints current date. 13 | shout() { 14 | echo -e " 15 | ################################################################################################# 16 | # $(date) 17 | # $1 18 | ################################################################################################# 19 | " 20 | } -------------------------------------------------------------------------------- /hack/run-lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # standard bash error handling 4 | set -o nounset # treat unset variables as an error and exit immediately. 5 | set -o errexit # exit immediately when a command fails. 6 | set -E # needs to be set if we want the ERR trap 7 | 8 | CURRENT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 9 | ROOT_PATH=$(cd "${CURRENT_DIR}/.." && pwd) 10 | GOLANGCI_LINT_VERSION="v1.55.2" 11 | TMP_DIR=$(mktemp -d) 12 | 13 | readonly CURRENT_DIR 14 | readonly GOLANGCI_LINT_VERSION 15 | readonly ROOT_PATH 16 | readonly TMP_DIR 17 | 18 | # shellcheck source=./hack/lib/utilities.sh 19 | source "${CURRENT_DIR}/lib/utilities.sh" || { 20 | echo 'Cannot load CI utilities.' 21 | exit 1 22 | } 23 | 24 | host::install::golangci() { 25 | mkdir -p "${TMP_DIR}/bin" 26 | export PATH="${TMP_DIR}/bin:${PATH}" 27 | 28 | shout "Install the golangci-lint ${GOLANGCI_LINT_VERSION} locally to a tempdir..." 29 | curl -sSfL -o "${TMP_DIR}/golangci-lint.sh" https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh 30 | chmod 700 "${TMP_DIR}/golangci-lint.sh" 31 | 32 | "${TMP_DIR}/golangci-lint.sh" -b "${TMP_DIR}/bin" ${GOLANGCI_LINT_VERSION} 33 | 34 | echo -e "${GREEN}√ install golangci-lint${NC}" 35 | } 36 | 37 | golangci::run_checks() { 38 | if [ -z "$(command -v golangci-lint)" ]; then 39 | echo "golangci-lint not found locally. Execute script with env variable INSTALL_DEPS=true" 40 | exit 1 41 | fi 42 | 43 | GOT_VER=$(golangci-lint version --format=short 2>&1) 44 | if [[ "v${GOT_VER}" != "${GOLANGCI_LINT_VERSION}" ]]; then 45 | echo -e "${RED}✗ golangci-lint version mismatch, expected ${GOLANGCI_LINT_VERSION}, available ${GOT_VER} ${NC}" 46 | exit 1 47 | fi 48 | 49 | shout "Run golangci-lint checks" 50 | 51 | # shellcheck disable=SC2046 52 | golangci-lint run $(golangci::fix_if_requested) "${ROOT_PATH}/..." 53 | 54 | echo -e "${GREEN}√ run golangci-lint${NC}" 55 | } 56 | 57 | golangci::fix_if_requested() { 58 | if [[ "${LINT_FORCE_FIX:-x}" == "true" ]]; then 59 | echo "--fix" 60 | fi 61 | } 62 | 63 | docker::run_dockerfile_checks() { 64 | shout "Run hadolint Dockerfile checks" 65 | docker run --rm -i hadolint/hadolint <"${ROOT_PATH}/Dockerfile" 66 | echo -e "${GREEN}√ run hadolint${NC}" 67 | } 68 | 69 | shellcheck::run_checks() { 70 | shout "Run shellcheck checks" 71 | docker run --rm -v "$ROOT_PATH":/mnt koalaman/shellcheck:stable -x ./hack/*.sh 72 | echo -e "${GREEN}√ run shellcheck${NC}" 73 | } 74 | 75 | main() { 76 | if [[ "${INSTALL_DEPS:-x}" == "true" ]]; then 77 | host::install::golangci 78 | fi 79 | 80 | golangci::run_checks 81 | 82 | docker::run_dockerfile_checks 83 | 84 | shellcheck::run_checks 85 | } 86 | 87 | main 88 | -------------------------------------------------------------------------------- /hack/run-test-integration.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # standard bash error handling 4 | set -o nounset # treat unset variables as an error and exit immediately. 5 | set -o errexit # exit immediately when a command fails. 6 | set -E # needs to be set if we want the ERR trap 7 | 8 | CURRENT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 9 | ROOT_PATH=$(cd "${CURRENT_DIR}/.." && pwd) 10 | TEST="" 11 | readonly CURRENT_DIR 12 | readonly ROOT_PATH 13 | 14 | # shellcheck source=./hack/lib/utilities.sh 15 | source "${CURRENT_DIR}/lib/utilities.sh" || { 16 | echo 'Cannot load CI utilities.' 17 | exit 1 18 | } 19 | 20 | pushd "${ROOT_PATH}" >/dev/null 21 | 22 | # Exit handler. This function is called anytime an EXIT signal is received. 23 | # This function should never be explicitly called. 24 | function _trap_exit() { 25 | popd >/dev/null 26 | } 27 | trap _trap_exit EXIT 28 | 29 | function print_info() { 30 | echo -e "${INVERTED}" 31 | echo "USER: ${USER:-"unknown"}" 32 | echo "PATH: ${PATH:-"unknown"}" 33 | echo "GOPATH: ${GOPATH:-"unknown"}" 34 | echo -e "${NC}" 35 | } 36 | 37 | function test::integration() { 38 | shout "? go test integration" 39 | 40 | # Check if tests passed 41 | # shellcheck disable=SC2046 42 | if ! go test -v -tags=integration $(test::run::specific) ./tests/integration/... $(test::update_golden); then 43 | echo -e "${RED}✗ go test integration\n${NC}" 44 | exit 1 45 | else 46 | echo -e "${GREEN}√ go test integration${NC}" 47 | fi 48 | } 49 | 50 | function test::run::specific() { 51 | if [[ -n "${TEST}" ]]; then 52 | echo "-run=${TEST}" 53 | fi 54 | } 55 | function test::update_golden() { 56 | if [[ "${UPDATE_GOLDEN:-"false"}" == "true" ]]; then 57 | echo "-update" 58 | fi 59 | } 60 | 61 | function main() { 62 | print_info 63 | 64 | test::integration 65 | } 66 | 67 | main 68 | -------------------------------------------------------------------------------- /hack/run-test-unit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # standard bash error handling 4 | set -o nounset # treat unset variables as an error and exit immediately. 5 | set -o errexit # exit immediately when a command fails. 6 | set -E # needs to be set if we want the ERR trap 7 | 8 | CURRENT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 9 | ROOT_PATH=$(cd "${CURRENT_DIR}/.." && pwd) 10 | 11 | readonly CURRENT_DIR 12 | readonly ROOT_PATH 13 | 14 | # shellcheck source=./hack/lib/utilities.sh 15 | source "${CURRENT_DIR}/lib/utilities.sh" || { 16 | echo 'Cannot load CI utilities.' 17 | exit 1 18 | } 19 | 20 | pushd "${ROOT_PATH}" >/dev/null 21 | 22 | # Exit handler. This function is called anytime an EXIT signal is received. 23 | # This function should never be explicitly called. 24 | function _trap_exit() { 25 | popd >/dev/null 26 | } 27 | trap _trap_exit EXIT 28 | 29 | function print_info() { 30 | echo -e "${INVERTED}" 31 | echo "USER: ${USER:-"unknown"}" 32 | echo "PATH: ${PATH:-"unknown"}" 33 | echo "GOPATH: ${GOPATH:-"unknown"}" 34 | echo -e "${NC}" 35 | } 36 | 37 | function test::go_modules() { 38 | shout "? go mod tidy" 39 | go mod tidy 40 | STATUS=$(git status --porcelain go.mod go.sum) 41 | if [ -n "$STATUS" ]; then 42 | echo -e "${RED}✗ go mod tidy modified go.mod and/or go.sum${NC}" 43 | exit 1 44 | else 45 | echo -e "${GREEN}√ go mod tidy${NC}" 46 | fi 47 | } 48 | 49 | function test::unit() { 50 | shout "? go test" 51 | 52 | # Check if tests passed 53 | if ! go test -race -coverprofile="${ROOT_PATH}/coverage.txt" ./...; then 54 | echo -e "${RED}✗ go test\n${NC}" 55 | exit 1 56 | else 57 | echo -e "${GREEN}√ go test${NC}" 58 | fi 59 | } 60 | 61 | function main() { 62 | print_info 63 | 64 | test::go_modules 65 | 66 | test::unit 67 | } 68 | 69 | main 70 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader on 2019-11-12T14:20:06Z. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 132 | } 133 | echoerr() { 134 | echo "$@" 1>&2 135 | } 136 | log_prefix() { 137 | echo "$0" 138 | } 139 | _logp=6 140 | log_set_priority() { 141 | _logp="$1" 142 | } 143 | log_priority() { 144 | if test -z "$1"; then 145 | echo "$_logp" 146 | return 147 | fi 148 | [ "$1" -le "$_logp" ] 149 | } 150 | log_tag() { 151 | case $1 in 152 | 0) echo "emerg" ;; 153 | 1) echo "alert" ;; 154 | 2) echo "crit" ;; 155 | 3) echo "err" ;; 156 | 4) echo "warning" ;; 157 | 5) echo "notice" ;; 158 | 6) echo "info" ;; 159 | 7) echo "debug" ;; 160 | *) echo "$1" ;; 161 | esac 162 | } 163 | log_debug() { 164 | log_priority 7 || return 0 165 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 166 | } 167 | log_info() { 168 | log_priority 6 || return 0 169 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 170 | } 171 | log_err() { 172 | log_priority 3 || return 0 173 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 174 | } 175 | log_crit() { 176 | log_priority 2 || return 0 177 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 178 | } 179 | uname_os() { 180 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 181 | case "$os" in 182 | cygwin_nt*) os="windows" ;; 183 | mingw*) os="windows" ;; 184 | msys_nt*) os="windows" ;; 185 | esac 186 | echo "$os" 187 | } 188 | uname_arch() { 189 | arch=$(uname -m) 190 | case $arch in 191 | x86_64) arch="amd64" ;; 192 | x86) arch="386" ;; 193 | i686) arch="386" ;; 194 | i386) arch="386" ;; 195 | aarch64) arch="arm64" ;; 196 | armv5*) arch="armv5" ;; 197 | armv6*) arch="armv6" ;; 198 | armv7*) arch="armv7" ;; 199 | esac 200 | echo ${arch} 201 | } 202 | uname_os_check() { 203 | os=$(uname_os) 204 | case "$os" in 205 | darwin) return 0 ;; 206 | dragonfly) return 0 ;; 207 | freebsd) return 0 ;; 208 | linux) return 0 ;; 209 | android) return 0 ;; 210 | nacl) return 0 ;; 211 | netbsd) return 0 ;; 212 | openbsd) return 0 ;; 213 | plan9) return 0 ;; 214 | solaris) return 0 ;; 215 | windows) return 0 ;; 216 | esac 217 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 218 | return 1 219 | } 220 | uname_arch_check() { 221 | arch=$(uname_arch) 222 | case "$arch" in 223 | 386) return 0 ;; 224 | amd64) return 0 ;; 225 | arm64) return 0 ;; 226 | armv5) return 0 ;; 227 | armv6) return 0 ;; 228 | armv7) return 0 ;; 229 | ppc64) return 0 ;; 230 | ppc64le) return 0 ;; 231 | mips) return 0 ;; 232 | mipsle) return 0 ;; 233 | mips64) return 0 ;; 234 | mips64le) return 0 ;; 235 | s390x) return 0 ;; 236 | amd64p32) return 0 ;; 237 | esac 238 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 239 | return 1 240 | } 241 | untar() { 242 | tarball=$1 243 | case "${tarball}" in 244 | *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;; 245 | *.tar) tar --no-same-owner -xf "${tarball}" ;; 246 | *.zip) unzip "${tarball}" ;; 247 | *) 248 | log_err "untar unknown archive format for ${tarball}" 249 | return 1 250 | ;; 251 | esac 252 | } 253 | http_download_curl() { 254 | local_file=$1 255 | source_url=$2 256 | header=$3 257 | if [ -z "$header" ]; then 258 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 259 | else 260 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 261 | fi 262 | if [ "$code" != "200" ]; then 263 | log_debug "http_download_curl received HTTP status $code" 264 | return 1 265 | fi 266 | return 0 267 | } 268 | http_download_wget() { 269 | local_file=$1 270 | source_url=$2 271 | header=$3 272 | if [ -z "$header" ]; then 273 | wget -q -O "$local_file" "$source_url" 274 | else 275 | wget -q --header "$header" -O "$local_file" "$source_url" 276 | fi 277 | } 278 | http_download() { 279 | log_debug "http_download $2" 280 | if is_command curl; then 281 | http_download_curl "$@" 282 | return 283 | elif is_command wget; then 284 | http_download_wget "$@" 285 | return 286 | fi 287 | log_crit "http_download unable to find wget or curl" 288 | return 1 289 | } 290 | http_copy() { 291 | tmp=$(mktemp) 292 | http_download "${tmp}" "$1" "$2" || return 1 293 | body=$(cat "$tmp") 294 | rm -f "${tmp}" 295 | echo "$body" 296 | } 297 | github_release() { 298 | owner_repo=$1 299 | version=$2 300 | test -z "$version" && version="latest" 301 | giturl="https://github.com/${owner_repo}/releases/${version}" 302 | json=$(http_copy "$giturl" "Accept:application/json") 303 | test -z "$json" && return 1 304 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 305 | test -z "$version" && return 1 306 | echo "$version" 307 | } 308 | hash_sha256() { 309 | TARGET=${1:-/dev/stdin} 310 | if is_command gsha256sum; then 311 | hash=$(gsha256sum "$TARGET") || return 1 312 | echo "$hash" | cut -d ' ' -f 1 313 | elif is_command sha256sum; then 314 | hash=$(sha256sum "$TARGET") || return 1 315 | echo "$hash" | cut -d ' ' -f 1 316 | elif is_command shasum; then 317 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 318 | echo "$hash" | cut -d ' ' -f 1 319 | elif is_command openssl; then 320 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 321 | echo "$hash" | cut -d ' ' -f a 322 | else 323 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 324 | return 1 325 | fi 326 | } 327 | hash_sha256_verify() { 328 | TARGET=$1 329 | checksums=$2 330 | if [ -z "$checksums" ]; then 331 | log_err "hash_sha256_verify checksum file not specified in arg2" 332 | return 1 333 | fi 334 | BASENAME=${TARGET##*/} 335 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 336 | if [ -z "$want" ]; then 337 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 338 | return 1 339 | fi 340 | got=$(hash_sha256 "$TARGET") 341 | if [ "$want" != "$got" ]; then 342 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 343 | return 1 344 | fi 345 | } 346 | cat /dev/null < 0 { 39 | msg := fmt.Sprintf("Pattern %q shadows the following patterns:\n%s\nEntries should go from least-specific to most-specific.", entry.Pattern, c.listFormatFunc(shadowed)) 40 | bldr.ReportIssue(msg, WithEntry(entry)) 41 | } 42 | previousEntries = append(previousEntries, entry) 43 | } 44 | 45 | return bldr.Output(), nil 46 | } 47 | 48 | // listFormatFunc is a basic formatter that outputs a bullet point list of the pattern. 49 | func (c *AvoidShadowing) listFormatFunc(es []codeowners.Entry) string { 50 | points := make([]string, len(es)) 51 | for i, err := range es { 52 | points[i] = fmt.Sprintf(" * %d: %q", err.LineNo, err.Pattern) 53 | } 54 | 55 | return strings.Join(points, "\n") 56 | } 57 | 58 | // Name returns human readable name of the validator 59 | func (AvoidShadowing) Name() string { 60 | return "[Experimental] Avoid Shadowing Checker" 61 | } 62 | 63 | // endWithSlash adds a trailing slash to a string if it doesn't already end with one. 64 | // This is useful when matching CODEOWNERS pattern because the trailing slash is optional. 65 | func endWithSlash(s string) string { 66 | if !strings.HasSuffix(s, "/") { 67 | return s + "/" 68 | } 69 | return s 70 | } 71 | 72 | // wildCardToRegexp converts a wildcard pattern to a regular expression pattern. 73 | func wildCardToRegexp(pattern string) (*regexp.Regexp, error) { 74 | var result strings.Builder 75 | for i, literal := range strings.Split(pattern, "*") { 76 | // Replace * with .* 77 | if i > 0 { 78 | result.WriteString(".*") 79 | } 80 | 81 | // Quote any regular expression meta characters in the 82 | // literal text. 83 | result.WriteString(regexp.QuoteMeta(literal)) 84 | } 85 | return regexp.Compile("^" + result.String() + "$") 86 | } 87 | -------------------------------------------------------------------------------- /internal/check/avoid_shadowing_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "go.szostok.io/codeowners-validator/internal/check" 8 | "go.szostok.io/codeowners-validator/internal/ptr" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestAvoidShadowing(t *testing.T) { 15 | tests := map[string]struct { 16 | codeownersInput string 17 | expectedIssues []check.Issue 18 | }{ 19 | "Should report info about shadowed entries": { 20 | codeownersInput: ` 21 | /build/logs/ @doctocat 22 | /script @mszostok 23 | 24 | # Shadows 25 | * @s1 26 | /s*/ @s2 27 | /s* @s3 28 | /b* @s4 29 | /b*/logs @s5 30 | 31 | # OK 32 | /b*/other @o1 33 | /script/* @o2 34 | `, 35 | expectedIssues: []check.Issue{ 36 | { 37 | Severity: check.Error, 38 | LineNo: ptr.Uint64Ptr(6), 39 | Message: `Pattern "*" shadows the following patterns: 40 | * 2: "/build/logs/" 41 | * 3: "/script" 42 | Entries should go from least-specific to most-specific.`, 43 | }, 44 | { 45 | Severity: check.Error, 46 | LineNo: ptr.Uint64Ptr(7), 47 | Message: `Pattern "/s*/" shadows the following patterns: 48 | * 3: "/script" 49 | Entries should go from least-specific to most-specific.`, 50 | }, 51 | { 52 | Severity: check.Error, 53 | LineNo: ptr.Uint64Ptr(8), 54 | Message: `Pattern "/s*" shadows the following patterns: 55 | * 3: "/script" 56 | * 7: "/s*/" 57 | Entries should go from least-specific to most-specific.`, 58 | }, 59 | { 60 | Severity: check.Error, 61 | LineNo: ptr.Uint64Ptr(9), 62 | Message: `Pattern "/b*" shadows the following patterns: 63 | * 2: "/build/logs/" 64 | Entries should go from least-specific to most-specific.`, 65 | }, 66 | { 67 | Severity: check.Error, 68 | LineNo: ptr.Uint64Ptr(10), 69 | Message: `Pattern "/b*/logs" shadows the following patterns: 70 | * 2: "/build/logs/" 71 | Entries should go from least-specific to most-specific.`, 72 | }, 73 | }, 74 | }, 75 | "Should not report any issues with correct CODEOWNERS file": { 76 | codeownersInput: FixtureValidCODEOWNERS, 77 | expectedIssues: nil, 78 | }, 79 | } 80 | 81 | for tn, tc := range tests { 82 | t.Run(tn, func(t *testing.T) { 83 | // given 84 | sut := check.NewAvoidShadowing() 85 | 86 | // when 87 | out, err := sut.Check(context.TODO(), LoadInput(tc.codeownersInput)) 88 | 89 | // then 90 | require.NoError(t, err) 91 | assert.ElementsMatch(t, tc.expectedIssues, out.Issues) 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/check/duplicated_pattern.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "go.szostok.io/codeowners-validator/internal/ctxutil" 9 | "go.szostok.io/codeowners-validator/pkg/codeowners" 10 | ) 11 | 12 | // DuplicatedPattern validates if CODEOWNERS file does not contain 13 | // the duplicated lines with the same file pattern. 14 | type DuplicatedPattern struct{} 15 | 16 | // NewDuplicatedPattern returns instance of the DuplicatedPattern 17 | func NewDuplicatedPattern() *DuplicatedPattern { 18 | return &DuplicatedPattern{} 19 | } 20 | 21 | // Check searches for doubles paths(patterns) in CODEOWNERS file. 22 | func (d *DuplicatedPattern) Check(ctx context.Context, in Input) (Output, error) { 23 | var bldr OutputBuilder 24 | 25 | // TODO(mszostok): decide if the `CodeownersEntries` entry by default should be 26 | // indexed by pattern (`map[string][]codeowners.Entry{}`) 27 | // Required changes in pkg/codeowners/owners.go. 28 | patterns := map[string][]codeowners.Entry{} 29 | for _, entry := range in.CodeownersEntries { 30 | if ctxutil.ShouldExit(ctx) { 31 | return Output{}, ctx.Err() 32 | } 33 | 34 | patterns[entry.Pattern] = append(patterns[entry.Pattern], entry) 35 | } 36 | 37 | for name, entries := range patterns { 38 | if len(entries) > 1 { 39 | msg := fmt.Sprintf("Pattern %q is defined %d times in lines:\n%s", name, len(entries), d.listFormatFunc(entries)) 40 | bldr.ReportIssue(msg) 41 | } 42 | } 43 | 44 | return bldr.Output(), nil 45 | } 46 | 47 | // listFormatFunc is a basic formatter that outputs a bullet point list of the pattern. 48 | func (d *DuplicatedPattern) listFormatFunc(es []codeowners.Entry) string { 49 | points := make([]string, len(es)) 50 | for i, err := range es { 51 | points[i] = fmt.Sprintf(" * %d: with owners: %s", err.LineNo, err.Owners) 52 | } 53 | 54 | return strings.Join(points, "\n") 55 | } 56 | 57 | // Name returns human readable name of the validator. 58 | func (DuplicatedPattern) Name() string { 59 | return "Duplicated Pattern Checker" 60 | } 61 | -------------------------------------------------------------------------------- /internal/check/duplicated_pattern_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "go.szostok.io/codeowners-validator/internal/check" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestDuplicatedPattern(t *testing.T) { 14 | tests := map[string]struct { 15 | codeownersInput string 16 | expectedIssues []check.Issue 17 | }{ 18 | "Should report info about duplicated entries": { 19 | codeownersInput: ` 20 | * @global-owner1 @global-owner2 21 | 22 | /build/logs/ @doctocat 23 | /build/logs/ @doctocat 24 | 25 | /script @mszostok 26 | /script m.t@g.com 27 | `, 28 | expectedIssues: []check.Issue{ 29 | { 30 | Severity: check.Error, 31 | LineNo: nil, 32 | Message: `Pattern "/build/logs/" is defined 2 times in lines: 33 | * 4: with owners: [@doctocat] 34 | * 5: with owners: [@doctocat]`, 35 | }, 36 | { 37 | Severity: check.Error, 38 | LineNo: nil, 39 | Message: `Pattern "/script" is defined 2 times in lines: 40 | * 7: with owners: [@mszostok] 41 | * 8: with owners: [m.t@g.com]`, 42 | }, 43 | }, 44 | }, 45 | "Should not report any issues with correct CODEOWNERS file": { 46 | codeownersInput: FixtureValidCODEOWNERS, 47 | expectedIssues: nil, 48 | }, 49 | } 50 | 51 | for tn, tc := range tests { 52 | t.Run(tn, func(t *testing.T) { 53 | // given 54 | sut := check.NewDuplicatedPattern() 55 | 56 | // when 57 | out, err := sut.Check(context.TODO(), LoadInput(tc.codeownersInput)) 58 | 59 | // then 60 | require.NoError(t, err) 61 | assert.ElementsMatch(t, tc.expectedIssues, out.Issues) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/check/file_exists.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "go.szostok.io/codeowners-validator/internal/ctxutil" 10 | 11 | "github.com/mattn/go-zglob" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type FileExist struct{} 16 | 17 | func NewFileExist() *FileExist { 18 | return &FileExist{} 19 | } 20 | 21 | func (f *FileExist) Check(ctx context.Context, in Input) (Output, error) { 22 | var bldr OutputBuilder 23 | 24 | for _, entry := range in.CodeownersEntries { 25 | if ctxutil.ShouldExit(ctx) { 26 | return Output{}, ctx.Err() 27 | } 28 | 29 | fullPath := filepath.Join(in.RepoDir, f.fnmatchPattern(entry.Pattern)) 30 | matches, err := zglob.Glob(fullPath) 31 | switch { 32 | case err == nil: 33 | case errors.Is(err, os.ErrNotExist): 34 | msg := fmt.Sprintf("%q does not match any files in repository", entry.Pattern) 35 | bldr.ReportIssue(msg, WithEntry(entry)) 36 | continue 37 | default: 38 | return Output{}, errors.Wrapf(err, "while checking if there is any file in %s matching pattern %s", in.RepoDir, entry.Pattern) 39 | } 40 | 41 | if len(matches) == 0 { 42 | msg := fmt.Sprintf("%q does not match any files in repository", entry.Pattern) 43 | bldr.ReportIssue(msg, WithEntry(entry)) 44 | } 45 | } 46 | 47 | return bldr.Output(), nil 48 | } 49 | 50 | func (*FileExist) fnmatchPattern(pattern string) string { 51 | if len(pattern) >= 2 && pattern[:1] == "*" && pattern[1:2] != "*" { 52 | return "**/" + pattern 53 | } 54 | 55 | return pattern 56 | } 57 | 58 | func (*FileExist) Name() string { 59 | return "File Exist Checker" 60 | } 61 | -------------------------------------------------------------------------------- /internal/check/file_exists_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "go.szostok.io/codeowners-validator/internal/check" 11 | "go.szostok.io/codeowners-validator/internal/ptr" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // TestFileExists validates that file exists checker supports 18 | // syntax used by CODEOWNERS. As the CODEOWNERS file uses a pattern that 19 | // follows the same rules used in gitignore files, the test cases cover 20 | // patterns from this document: https://git-scm.com/docs/gitignore#_pattern_format 21 | func TestFileExists(t *testing.T) { 22 | tests := map[string]struct { 23 | codeownersInput string 24 | expectedIssues []check.Issue 25 | paths []string 26 | }{ 27 | "Should found JS file": { 28 | codeownersInput: ` 29 | *.js @pico 30 | `, 31 | paths: []string{ 32 | "/somewhere/over/the/rainbow/here/it/is.js", 33 | "/somewhere/not/here/it/is.go", 34 | }, 35 | }, 36 | "Should match directory 'foo' anywhere": { 37 | codeownersInput: ` 38 | **/foo @pico 39 | `, 40 | paths: []string{ 41 | "/somewhere/over/the/foo/here/it/is.js", 42 | }, 43 | }, 44 | "Should match file 'foo' anywhere": { 45 | codeownersInput: ` 46 | **/foo.js @pico 47 | `, 48 | paths: []string{ 49 | "/somewhere/over/the/rainbow/here/it/foo.js", 50 | }, 51 | }, 52 | "Should match directory 'bar' anywhere that is directly under directory 'foo'": { 53 | codeownersInput: ` 54 | **/foo/bar @bello 55 | `, 56 | paths: []string{ 57 | "/somewhere/over/the/foo/bar/it/is.js", 58 | }, 59 | }, 60 | "Should match file 'bar' anywhere that is directly under directory 'foo'": { 61 | codeownersInput: ` 62 | **/foo/bar.js @bello 63 | `, 64 | paths: []string{ 65 | "/somewhere/over/the/foo/bar.js", 66 | }, 67 | }, 68 | "Should match all files inside directory 'abc'": { 69 | codeownersInput: ` 70 | abc/** @bello 71 | `, 72 | paths: []string{ 73 | "/abc/over/the/rainbow/bar.js", 74 | }, 75 | }, 76 | "Should match 'a/b', 'a/x/b', 'a/x/y/b' and so on": { 77 | codeownersInput: ` 78 | a/**/b @bello 79 | `, 80 | paths: []string{ 81 | "a/somewhere/over/the/b/foo.js", 82 | }, 83 | }, 84 | // https://github.community/t/codeowners-file-with-a-not-file-type-condition/1423 85 | "Should not match with negation pattern": { 86 | codeownersInput: ` 87 | !/codeowners-validator @pico 88 | `, 89 | paths: []string{ 90 | "/somewhere/over/the/rainbow/here/it/is.js", 91 | }, 92 | expectedIssues: []check.Issue{ 93 | newErrIssue(`"!/codeowners-validator" does not match any files in repository`), 94 | }, 95 | }, 96 | "Should not found JS file": { 97 | codeownersInput: ` 98 | *.js @pico 99 | `, 100 | expectedIssues: []check.Issue{ 101 | newErrIssue(`"*.js" does not match any files in repository`), 102 | }, 103 | }, 104 | "Should not match directory 'foo' anywhere": { 105 | codeownersInput: ` 106 | **/foo @pico 107 | `, 108 | expectedIssues: []check.Issue{ 109 | newErrIssue(`"**/foo" does not match any files in repository`), 110 | }, 111 | }, 112 | "Should not match file 'foo' anywhere": { 113 | codeownersInput: ` 114 | **/foo.js @pico 115 | `, 116 | expectedIssues: []check.Issue{ 117 | newErrIssue(`"**/foo.js" does not match any files in repository`), 118 | }, 119 | }, 120 | "Should no match directory 'bar' anywhere that is directly under directory 'foo'": { 121 | codeownersInput: ` 122 | **/foo/bar @bello 123 | `, 124 | expectedIssues: []check.Issue{ 125 | newErrIssue(`"**/foo/bar" does not match any files in repository`), 126 | }, 127 | }, 128 | "Should not match file 'bar' anywhere that is directly under directory 'foo'": { 129 | codeownersInput: ` 130 | **/foo/bar.js @bello 131 | `, 132 | expectedIssues: []check.Issue{ 133 | newErrIssue(`"**/foo/bar.js" does not match any files in repository`), 134 | }, 135 | }, 136 | "Should not match all files inside directory 'abc'": { 137 | codeownersInput: ` 138 | abc/** @bello 139 | `, 140 | expectedIssues: []check.Issue{ 141 | newErrIssue(`"abc/**" does not match any files in repository`), 142 | }, 143 | }, 144 | "Should not match 'a/**/b'": { 145 | codeownersInput: ` 146 | a/**/b @bello 147 | `, 148 | expectedIssues: []check.Issue{ 149 | newErrIssue(`"a/**/b" does not match any files in repository`), 150 | }, 151 | }, 152 | } 153 | 154 | for tn, tc := range tests { 155 | t.Run(tn, func(t *testing.T) { 156 | // given 157 | tmp, err := os.MkdirTemp("", "file-checker") 158 | require.NoError(t, err) 159 | defer func() { 160 | assert.NoError(t, os.RemoveAll(tmp)) 161 | }() 162 | 163 | initFSStructure(t, tmp, tc.paths) 164 | 165 | fchecker := check.NewFileExist() 166 | 167 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) 168 | defer cancel() 169 | 170 | // when 171 | in := LoadInput(tc.codeownersInput) 172 | in.RepoDir = tmp 173 | out, err := fchecker.Check(ctx, in) 174 | 175 | // then 176 | require.NoError(t, err) 177 | assert.ElementsMatch(t, tc.expectedIssues, out.Issues) 178 | }) 179 | } 180 | } 181 | 182 | func TestFileExistCheckFileSystemFailure(t *testing.T) { 183 | // given 184 | tmpdir, err := os.MkdirTemp("", "file-checker") 185 | require.NoError(t, err) 186 | defer func() { 187 | assert.NoError(t, os.RemoveAll(tmpdir)) 188 | }() 189 | 190 | err = os.MkdirAll(filepath.Join(tmpdir, "foo"), 0o222) 191 | require.NoError(t, err) 192 | 193 | in := LoadInput("* @pico") 194 | in.RepoDir = tmpdir 195 | 196 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) 197 | defer cancel() 198 | 199 | // when 200 | out, err := check.NewFileExist().Check(ctx, in) 201 | 202 | // then 203 | require.Error(t, err) 204 | assert.Empty(t, out) 205 | } 206 | 207 | func newErrIssue(msg string) check.Issue { 208 | return check.Issue{ 209 | Severity: check.Error, 210 | LineNo: ptr.Uint64Ptr(2), 211 | Message: msg, 212 | } 213 | } 214 | func initFSStructure(t *testing.T, base string, paths []string) { 215 | t.Helper() 216 | 217 | for _, p := range paths { 218 | if filepath.Ext(p) == "" { 219 | err := os.MkdirAll(filepath.Join(base, p), 0o755) 220 | require.NoError(t, err) 221 | } else { 222 | dir := filepath.Dir(p) 223 | 224 | err := os.MkdirAll(filepath.Join(base, dir), 0o755) 225 | require.NoError(t, err) 226 | 227 | err = os.WriteFile(filepath.Join(base, p), []byte("hakuna-matata"), 0o600) 228 | require.NoError(t, err) 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /internal/check/helpers_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "go.szostok.io/codeowners-validator/internal/check" 10 | 11 | "go.szostok.io/codeowners-validator/pkg/codeowners" 12 | ) 13 | 14 | var FixtureValidCODEOWNERS = ` 15 | # These owners will be the default owners for everything 16 | * @global-owner1 @global-owner2 17 | 18 | # js owner 19 | *.js @js-owner 20 | 21 | *.go docs@example.com 22 | 23 | /build/logs/ @doctocat 24 | 25 | /script m.t@g.com 26 | ` 27 | 28 | func LoadInput(in string) check.Input { 29 | r := strings.NewReader(in) 30 | 31 | return check.Input{ 32 | CodeownersEntries: codeowners.ParseCodeowners(r), 33 | } 34 | } 35 | 36 | func assertIssue(t *testing.T, expIssue *check.Issue, gotIssues []check.Issue) { 37 | t.Helper() 38 | 39 | if expIssue != nil { 40 | require.Len(t, gotIssues, 1) 41 | assert.EqualValues(t, *expIssue, gotIssues[0]) 42 | } else { 43 | assert.Empty(t, gotIssues) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/check/not_owned_file.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "go.szostok.io/codeowners-validator/internal/ctxutil" 11 | "go.szostok.io/codeowners-validator/pkg/codeowners" 12 | 13 | "github.com/hashicorp/go-multierror" 14 | "github.com/pkg/errors" 15 | "gopkg.in/pipe.v2" 16 | ) 17 | 18 | type NotOwnedFileConfig struct { 19 | // TrustWorkspace sets the global gif config 20 | // to trust a given repository path 21 | // see: https://github.com/actions/checkout/issues/766 22 | TrustWorkspace bool `envconfig:"default=false"` 23 | SkipPatterns []string `envconfig:"optional"` 24 | Subdirectories []string `envconfig:"optional"` 25 | } 26 | 27 | type NotOwnedFile struct { 28 | skipPatterns map[string]struct{} 29 | subDirectories []string 30 | trustWorkspace bool 31 | } 32 | 33 | func NewNotOwnedFile(cfg NotOwnedFileConfig) *NotOwnedFile { 34 | skip := map[string]struct{}{} 35 | for _, p := range cfg.SkipPatterns { 36 | skip[p] = struct{}{} 37 | } 38 | 39 | return &NotOwnedFile{ 40 | skipPatterns: skip, 41 | subDirectories: cfg.Subdirectories, 42 | trustWorkspace: cfg.TrustWorkspace, 43 | } 44 | } 45 | 46 | func (c *NotOwnedFile) Check(ctx context.Context, in Input) (output Output, err error) { 47 | if ctxutil.ShouldExit(ctx) { 48 | return Output{}, ctx.Err() 49 | } 50 | 51 | var bldr OutputBuilder 52 | 53 | if len(in.CodeownersEntries) == 0 { 54 | bldr.ReportIssue("The CODEOWNERS file is empty. The files in the repository don't have any owner.") 55 | return bldr.Output(), nil 56 | } 57 | 58 | patterns := c.patternsToBeIgnored(in.CodeownersEntries) 59 | 60 | if err := c.trustWorkspaceIfNeeded(in.RepoDir); err != nil { 61 | return Output{}, err 62 | } 63 | 64 | statusOut, err := c.GitCheckStatus(in.RepoDir) 65 | if err != nil { 66 | return Output{}, err 67 | } 68 | if len(statusOut) != 0 { 69 | bldr.ReportIssue("git state is dirty: commit all changes before executing this check") 70 | return bldr.Output(), nil 71 | } 72 | 73 | defer func() { 74 | errReset := c.GitResetCurrentBranch(in.RepoDir) 75 | if err != nil { 76 | output = Output{} 77 | err = multierror.Append(err, errReset).ErrorOrNil() 78 | } 79 | }() 80 | 81 | err = c.AppendToGitignoreFile(in.RepoDir, patterns) 82 | if err != nil { 83 | return Output{}, err 84 | } 85 | 86 | err = c.GitRemoveIgnoredFiles(in.RepoDir) 87 | if err != nil { 88 | return Output{}, err 89 | } 90 | 91 | out, err := c.GitListFiles(in.RepoDir) 92 | if err != nil { 93 | return Output{}, err 94 | } 95 | 96 | lsOut := strings.TrimSpace(out) 97 | if lsOut != "" { 98 | lines := strings.Split(lsOut, "\n") 99 | msg := fmt.Sprintf("Found %d not owned files (skipped patterns: %q):\n%s", len(lines), c.skipPatternsList(), c.ListFormatFunc(lines)) 100 | bldr.ReportIssue(msg) 101 | } 102 | 103 | return bldr.Output(), nil 104 | } 105 | 106 | func (c *NotOwnedFile) patternsToBeIgnored(entries []codeowners.Entry) []string { 107 | var patterns []string 108 | for _, entry := range entries { 109 | if _, found := c.skipPatterns[entry.Pattern]; found { 110 | continue 111 | } 112 | patterns = append(patterns, entry.Pattern) 113 | } 114 | 115 | return patterns 116 | } 117 | 118 | func (c *NotOwnedFile) AppendToGitignoreFile(repoDir string, patterns []string) error { 119 | f, err := os.OpenFile(path.Join(repoDir, ".gitignore"), 120 | os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | defer f.Close() 126 | 127 | content := strings.Builder{} 128 | // ensure we are starting from new line 129 | content.WriteString("\n") 130 | for _, p := range patterns { 131 | content.WriteString(fmt.Sprintf("%s\n", p)) 132 | } 133 | 134 | _, err = f.WriteString(content.String()) 135 | if err != nil { 136 | return err 137 | } 138 | return nil 139 | } 140 | 141 | func (c *NotOwnedFile) GitRemoveIgnoredFiles(repoDir string) error { 142 | gitrm := pipe.Script( 143 | pipe.ChDir(repoDir), 144 | pipe.Line( 145 | pipe.Exec("git", "ls-files", "-ci", "--exclude-standard", "-z"), 146 | pipe.Exec("xargs", "-0", "-r", "git", "rm", "--cached"), 147 | ), 148 | ) 149 | 150 | _, stderr, err := pipe.DividedOutput(gitrm) 151 | if err != nil { 152 | return errors.Wrap(err, string(stderr)) 153 | } 154 | return nil 155 | } 156 | 157 | func (c *NotOwnedFile) GitCheckStatus(repoDir string) ([]byte, error) { 158 | gitstate := pipe.Script( 159 | pipe.ChDir(repoDir), 160 | pipe.Exec("git", "status", "--porcelain"), 161 | ) 162 | 163 | out, stderr, err := pipe.DividedOutput(gitstate) 164 | if err != nil { 165 | return nil, errors.Wrap(err, string(stderr)) 166 | } 167 | 168 | return out, nil 169 | } 170 | 171 | func (c *NotOwnedFile) GitResetCurrentBranch(repoDir string) error { 172 | gitreset := pipe.Script( 173 | pipe.ChDir(repoDir), 174 | pipe.Exec("git", "reset", "--hard"), 175 | ) 176 | _, stderr, err := pipe.DividedOutput(gitreset) 177 | if err != nil { 178 | return errors.Wrap(err, string(stderr)) 179 | } 180 | return nil 181 | } 182 | 183 | func (c *NotOwnedFile) GitListFiles(repoDir string) (string, error) { 184 | args := []string{"ls-files"} 185 | args = append(args, c.subDirectories...) 186 | 187 | gitls := pipe.Script( 188 | pipe.ChDir(repoDir), 189 | pipe.Exec("git", args...), 190 | ) 191 | 192 | stdout, stderr, err := pipe.DividedOutput(gitls) 193 | if err != nil { 194 | return "", errors.Wrap(err, string(stderr)) 195 | } 196 | 197 | return string(stdout), nil 198 | } 199 | 200 | func (c *NotOwnedFile) trustWorkspaceIfNeeded(repo string) error { 201 | if !c.trustWorkspace { 202 | return nil 203 | } 204 | 205 | gitadd := pipe.Exec("git", "config", "--global", "--add", "safe.directory", repo) 206 | _, stderr, err := pipe.DividedOutput(gitadd) 207 | if err != nil { 208 | return errors.Wrap(err, string(stderr)) 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func (c *NotOwnedFile) skipPatternsList() string { 215 | list := make([]string, 0, len(c.skipPatterns)) 216 | for k := range c.skipPatterns { 217 | list = append(list, k) 218 | } 219 | return strings.Join(list, ",") 220 | } 221 | 222 | // ListFormatFunc is a basic formatter that outputs 223 | // a bullet point list of the pattern. 224 | func (c *NotOwnedFile) ListFormatFunc(es []string) string { 225 | points := make([]string, len(es)) 226 | for i, err := range es { 227 | points[i] = fmt.Sprintf(" * %s", err) 228 | } 229 | 230 | return strings.Join(points, "\n") 231 | } 232 | 233 | // Name returns human-readable name of the validator 234 | func (NotOwnedFile) Name() string { 235 | return "[Experimental] Not Owned File Checker" 236 | } 237 | -------------------------------------------------------------------------------- /internal/check/package_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "go.szostok.io/codeowners-validator/internal/check" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestRespectingCanceledContext(t *testing.T) { 15 | must := func(checker check.Checker, err error) check.Checker { 16 | require.NoError(t, err) 17 | return checker 18 | } 19 | 20 | checkers := []check.Checker{ 21 | check.NewDuplicatedPattern(), 22 | check.NewFileExist(), 23 | check.NewValidSyntax(), 24 | check.NewNotOwnedFile(check.NotOwnedFileConfig{}), 25 | must(check.NewValidOwner(check.ValidOwnerConfig{Repository: "org/repo"}, nil, true)), 26 | } 27 | 28 | for _, checker := range checkers { 29 | sut := checker 30 | t.Run(checker.Name(), func(t *testing.T) { 31 | // given: canceled context 32 | ctx, cancel := context.WithCancel(context.Background()) 33 | cancel() 34 | 35 | // when 36 | out, err := sut.Check(ctx, LoadInput(FixtureValidCODEOWNERS)) 37 | 38 | // then 39 | assert.True(t, errors.Is(err, context.Canceled)) 40 | assert.Empty(t, out) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/check/valid_owner.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/mail" 8 | "strings" 9 | 10 | "go.szostok.io/codeowners-validator/internal/ctxutil" 11 | 12 | "github.com/google/go-github/v41/github" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | const scopeHeader = "X-OAuth-Scopes" 17 | 18 | var reqScopes = map[github.Scope]struct{}{ 19 | github.ScopeReadOrg: {}, 20 | } 21 | 22 | type ValidOwnerConfig struct { 23 | // Repository represents the GitHub repository against which 24 | // the external checks like teams and members validation should be executed. 25 | // It is in form 'owner/repository'. 26 | Repository string 27 | // IgnoredOwners contains a list of owners that should not be validated. 28 | // Defaults to @ghost. 29 | // More info about the @ghost user: https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-user-account/deleting-your-user-account 30 | // Tip on how @ghost can be used: https://github.community/t5/How-to-use-Git-and-GitHub/CODEOWNERS-file-with-a-NOT-file-type-condition/m-p/31013/highlight/true#M8523 31 | IgnoredOwners []string `envconfig:"default=@ghost"` 32 | // AllowUnownedPatterns specifies whether CODEOWNERS may have unowned files. For example: 33 | // 34 | // /infra/oncall-rotator/ @sre-team 35 | // /infra/oncall-rotator/oncall-config.yml 36 | // 37 | // The `/infra/oncall-rotator/oncall-config.yml` this file is not owned by anyone. 38 | AllowUnownedPatterns bool `envconfig:"default=true"` 39 | // OwnersMustBeTeams specifies whether owners must be teams in the same org as the repository 40 | OwnersMustBeTeams bool `envconfig:"default=false"` 41 | } 42 | 43 | // ValidOwner validates each owner 44 | type ValidOwner struct { 45 | ghClient *github.Client 46 | checkScopes bool 47 | orgMembers *map[string]struct{} 48 | orgName string 49 | orgTeams []*github.Team 50 | orgRepoName string 51 | ignOwners map[string]struct{} 52 | allowUnownedPatterns bool 53 | ownersMustBeTeams bool 54 | } 55 | 56 | // NewValidOwner returns new instance of the ValidOwner 57 | func NewValidOwner(cfg ValidOwnerConfig, ghClient *github.Client, checkScopes bool) (*ValidOwner, error) { 58 | split := strings.Split(cfg.Repository, "/") 59 | if len(split) != 2 { 60 | return nil, errors.Errorf("Wrong repository name. Expected pattern 'owner/repository', got '%s'", cfg.Repository) 61 | } 62 | 63 | ignOwners := map[string]struct{}{} 64 | for _, n := range cfg.IgnoredOwners { 65 | ignOwners[n] = struct{}{} 66 | } 67 | 68 | return &ValidOwner{ 69 | ghClient: ghClient, 70 | checkScopes: checkScopes, 71 | orgName: split[0], 72 | orgRepoName: split[1], 73 | ignOwners: ignOwners, 74 | allowUnownedPatterns: cfg.AllowUnownedPatterns, 75 | ownersMustBeTeams: cfg.OwnersMustBeTeams, 76 | }, nil 77 | } 78 | 79 | // Check if defined owners are the valid ones. 80 | // Allowed owner syntax: 81 | // @username 82 | // @org/team-name 83 | // user@example.com 84 | // source: https://help.github.com/articles/about-code-owners/#codeowners-syntax 85 | // 86 | // Checks: 87 | // - if owner is one of: GitHub user, org team, email address 88 | // - if GitHub user then check if have GitHub account 89 | // - if GitHub user then check if he/she is in organization 90 | // - if org team then check if exists in organization 91 | func (v *ValidOwner) Check(ctx context.Context, in Input) (Output, error) { 92 | var bldr OutputBuilder 93 | 94 | checkedOwners := map[string]struct{}{} 95 | 96 | for _, entry := range in.CodeownersEntries { 97 | if len(entry.Owners) == 0 && !v.allowUnownedPatterns { 98 | bldr.ReportIssue("Missing owner, at least one owner is required", WithEntry(entry), WithSeverity(Warning)) 99 | continue 100 | } 101 | 102 | for _, ownerName := range entry.Owners { 103 | if ctxutil.ShouldExit(ctx) { 104 | return Output{}, ctx.Err() 105 | } 106 | 107 | if v.isIgnoredOwner(ownerName) { 108 | continue 109 | } 110 | 111 | if _, alreadyChecked := checkedOwners[ownerName]; alreadyChecked { 112 | continue 113 | } 114 | 115 | validFn := v.selectValidateFn(ownerName) 116 | if err := validFn(ctx, ownerName); err != nil { 117 | bldr.ReportIssue(err.msg, WithEntry(entry)) 118 | if err.permanent { // Doesn't make sense to process further 119 | return bldr.Output(), nil 120 | } 121 | } 122 | checkedOwners[ownerName] = struct{}{} 123 | } 124 | } 125 | 126 | return bldr.Output(), nil 127 | } 128 | 129 | func isEmailAddress(s string) bool { 130 | _, err := mail.ParseAddress(s) 131 | return err == nil 132 | } 133 | 134 | func isGitHubTeam(s string) bool { 135 | hasPrefix := strings.HasPrefix(s, "@") 136 | containsSlash := strings.Contains(s, "/") 137 | split := strings.SplitN(s, "/", 3) // 3 is enough to confirm that is invalid + will not overflow the buffer 138 | return hasPrefix && containsSlash && len(split) == 2 && len(split[1]) > 0 139 | } 140 | 141 | func isGitHubUser(s string) bool { 142 | return !strings.Contains(s, "/") && strings.HasPrefix(s, "@") 143 | } 144 | 145 | func (v *ValidOwner) isIgnoredOwner(name string) bool { 146 | _, found := v.ignOwners[name] 147 | return found 148 | } 149 | 150 | func (v *ValidOwner) selectValidateFn(name string) func(context.Context, string) *validateError { 151 | switch { 152 | case v.ownersMustBeTeams: 153 | return func(ctx context.Context, s string) *validateError { 154 | if !isGitHubTeam(name) { 155 | return newValidateError("Only team owners allowed and %q is not a team", name) 156 | } 157 | return v.validateTeam(ctx, s) 158 | } 159 | case isGitHubTeam(name): 160 | return v.validateTeam 161 | case isGitHubUser(name): 162 | return v.validateGitHubUser 163 | case isEmailAddress(name): 164 | // TODO(mszostok): try to check if e-mail really exists 165 | return func(context.Context, string) *validateError { return nil } 166 | default: 167 | return func(_ context.Context, name string) *validateError { 168 | return newValidateError("Not valid owner definition %q", name) 169 | } 170 | } 171 | } 172 | 173 | func (v *ValidOwner) initOrgListTeams(ctx context.Context) *validateError { 174 | var teams []*github.Team 175 | req := &github.ListOptions{ 176 | PerPage: 100, 177 | } 178 | for { 179 | resultPage, resp, err := v.ghClient.Teams.ListTeams(ctx, v.orgName, req) 180 | if err != nil { // TODO(mszostok): implement retry? 181 | switch err := err.(type) { 182 | case *github.ErrorResponse: 183 | if err.Response.StatusCode == http.StatusUnauthorized { 184 | return newValidateError("Teams for organization %q could not be queried. Requires GitHub authorization.", v.orgName) 185 | } 186 | return newValidateError("HTTP error occurred while calling GitHub: %v", err) 187 | case *github.RateLimitError: 188 | return newValidateError("GitHub rate limit reached: %v", err.Message) 189 | default: 190 | return newValidateError("Unknown error occurred while calling GitHub: %v", err) 191 | } 192 | } 193 | teams = append(teams, resultPage...) 194 | if resp.NextPage == 0 { 195 | break 196 | } 197 | req.Page = resp.NextPage 198 | } 199 | 200 | v.orgTeams = teams 201 | 202 | return nil 203 | } 204 | 205 | func (v *ValidOwner) validateTeam(ctx context.Context, name string) *validateError { 206 | if v.orgTeams == nil { 207 | if err := v.initOrgListTeams(ctx); err != nil { 208 | return err.AsPermanent() 209 | } 210 | } 211 | 212 | // called after validation it's safe to work on `parts` slice 213 | parts := strings.SplitN(name, "/", 2) 214 | org := parts[0] 215 | org = strings.TrimPrefix(org, "@") 216 | team := parts[1] 217 | 218 | // GitHub normalizes name before comparison 219 | if !strings.EqualFold(org, v.orgName) { 220 | return newValidateError("Team %q does not belong to %q organization.", name, v.orgName) 221 | } 222 | 223 | teamExists := func() bool { 224 | for _, v := range v.orgTeams { 225 | // GitHub normalizes name before comparison 226 | if strings.EqualFold(v.GetSlug(), team) { 227 | return true 228 | } 229 | } 230 | return false 231 | } 232 | 233 | if !teamExists() { 234 | return newValidateError("Team %q does not exist in organization %q.", name, org) 235 | } 236 | 237 | // repo contains the permissions for the team slug given 238 | // TODO(mszostok): Switch to GraphQL API, see: 239 | // https://github.com/mszostok/codeowners-validator/pull/62#discussion_r561273525 240 | repo, _, err := v.ghClient.Teams.IsTeamRepoBySlug(ctx, v.orgName, team, org, v.orgRepoName) 241 | if err != nil { // TODO(mszostok): implement retry? 242 | switch err := err.(type) { 243 | case *github.ErrorResponse: 244 | switch err.Response.StatusCode { 245 | case http.StatusUnauthorized: 246 | return newValidateError( 247 | "Team permissions information for %q/%q could not be queried. Requires GitHub authorization.", 248 | org, v.orgRepoName) 249 | case http.StatusNotFound: 250 | return newValidateError( 251 | "Team %q does not have permissions associated with the repository %q.", 252 | team, v.orgRepoName) 253 | default: 254 | return newValidateError("HTTP error occurred while calling GitHub: %v", err) 255 | } 256 | case *github.RateLimitError: 257 | return newValidateError("GitHub rate limit reached: %v", err.Message) 258 | default: 259 | return newValidateError("Unknown error occurred while calling GitHub: %v", err) 260 | } 261 | } 262 | 263 | teamHasWritePermission := func() bool { 264 | for k, v := range repo.GetPermissions() { 265 | if !v { 266 | continue 267 | } 268 | 269 | switch k { 270 | case 271 | "admin", 272 | "maintain", 273 | "push": 274 | return true 275 | case 276 | "pull", 277 | "triage": 278 | } 279 | } 280 | 281 | return false 282 | } 283 | 284 | if !teamHasWritePermission() { 285 | return newValidateError( 286 | "Team %q cannot review PRs on %q as neither it nor any parent team has write permissions.", 287 | team, v.orgRepoName) 288 | } 289 | 290 | return nil 291 | } 292 | 293 | func (v *ValidOwner) validateGitHubUser(ctx context.Context, name string) *validateError { 294 | if v.orgMembers == nil { // TODO(mszostok): lazy init, make it more robust. 295 | if err := v.initOrgListMembers(ctx); err != nil { 296 | return newValidateError("Cannot initialize organization member list: %v", err).AsPermanent() 297 | } 298 | } 299 | 300 | userName := strings.TrimPrefix(name, "@") 301 | _, _, err := v.ghClient.Users.Get(ctx, userName) 302 | if err != nil { // TODO(mszostok): implement retry? 303 | switch err := err.(type) { 304 | case *github.ErrorResponse: 305 | if err.Response.StatusCode == http.StatusNotFound { 306 | return newValidateError("User %q does not have github account", name) 307 | } 308 | return newValidateError("HTTP error occurred while calling GitHub: %v", err).AsPermanent() 309 | case *github.RateLimitError: 310 | return newValidateError("GitHub rate limit reached: %v", err.Message).AsPermanent() 311 | default: 312 | return newValidateError("Unknown error occurred while calling GitHub: %v", err).AsPermanent() 313 | } 314 | } 315 | 316 | _, isMember := (*v.orgMembers)[userName] 317 | if !isMember { 318 | return newValidateError("User %q is not a member of the organization", name) 319 | } 320 | 321 | return nil 322 | } 323 | 324 | // There is a method to check if user is a org member 325 | // 326 | // client.Organizations.IsMember(context.Background(), "org-name", "user-name") 327 | // 328 | // But latency is too huge for checking each single user independent 329 | // better and faster is to ask for all members and cache them. 330 | func (v *ValidOwner) initOrgListMembers(ctx context.Context) error { 331 | opt := &github.ListMembersOptions{ 332 | ListOptions: github.ListOptions{PerPage: 100}, 333 | } 334 | 335 | var allMembers []*github.User 336 | for { 337 | users, resp, err := v.ghClient.Organizations.ListMembers(ctx, v.orgName, opt) 338 | if err != nil { 339 | return err 340 | } 341 | allMembers = append(allMembers, users...) 342 | if resp.NextPage == 0 { 343 | break 344 | } 345 | opt.Page = resp.NextPage 346 | } 347 | 348 | v.orgMembers = &map[string]struct{}{} 349 | for _, u := range allMembers { 350 | (*v.orgMembers)[u.GetLogin()] = struct{}{} 351 | } 352 | 353 | return nil 354 | } 355 | 356 | // Name returns human-readable name of the validator 357 | func (ValidOwner) Name() string { 358 | return "Valid Owner Checker" 359 | } 360 | 361 | // CheckSatisfied checks if this check has all requirements satisfied to be successfully executed. 362 | func (v *ValidOwner) CheckSatisfied(ctx context.Context) error { 363 | _, resp, err := v.ghClient.Repositories.Get(ctx, v.orgName, v.orgRepoName) 364 | if err != nil { 365 | switch err := err.(type) { 366 | case *github.ErrorResponse: 367 | if err.Response.StatusCode == http.StatusNotFound { 368 | return fmt.Errorf("repository %s/%s not found, or it's private and token doesn't have enough permission", v.orgName, v.orgRepoName) 369 | } 370 | return fmt.Errorf("HTTP error occurred while calling GitHub: %v", err) 371 | case *github.RateLimitError: 372 | return fmt.Errorf("GitHub rate limit reached: %v", err.Message) 373 | default: 374 | return fmt.Errorf("unknown error occurred while calling GitHub: %v", err) 375 | } 376 | } 377 | 378 | if !v.checkScopes { 379 | // If the GitHub client uses a GitHub App, the headers won't have scope information. 380 | // TODO: Call the https://api.github.com/app/installations and check if the `permission` field has `"members": "read" 381 | return nil 382 | } 383 | 384 | return v.checkRequiredScopes(resp.Header) 385 | } 386 | 387 | func (*ValidOwner) checkRequiredScopes(header http.Header) error { 388 | gotScopes := strings.Split(header.Get(scopeHeader), ",") 389 | presentScope := map[github.Scope]struct{}{} 390 | for _, scope := range gotScopes { 391 | scope = strings.TrimSpace(scope) 392 | presentScope[github.Scope(scope)] = struct{}{} 393 | } 394 | 395 | var missing []string 396 | for reqScope := range reqScopes { 397 | if _, found := presentScope[reqScope]; found { 398 | continue 399 | } 400 | missing = append(missing, string(reqScope)) 401 | } 402 | 403 | if len(missing) > 0 { 404 | return fmt.Errorf("missing scopes: %q", strings.Join(missing, ", ")) 405 | } 406 | 407 | return nil 408 | } 409 | -------------------------------------------------------------------------------- /internal/check/valid_owner_error.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import "fmt" 4 | 5 | type validateError struct { 6 | msg string 7 | permanent bool 8 | } 9 | 10 | func newValidateError(format string, a ...interface{}) *validateError { 11 | return &validateError{ 12 | msg: fmt.Sprintf(format, a...), 13 | } 14 | } 15 | 16 | func (err *validateError) AsPermanent() *validateError { 17 | err.permanent = true 18 | return err 19 | } 20 | -------------------------------------------------------------------------------- /internal/check/valid_owner_export_test.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | func IsValidOwner(owner string) bool { 4 | return isEmailAddress(owner) || isGitHubUser(owner) || isGitHubTeam(owner) 5 | } 6 | -------------------------------------------------------------------------------- /internal/check/valid_owner_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "go.szostok.io/codeowners-validator/internal/check" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "go.szostok.io/codeowners-validator/internal/ptr" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestValidOwnerChecker(t *testing.T) { 17 | tests := map[string]struct { 18 | owner string 19 | isValid bool 20 | }{ 21 | "Invalid Email": { 22 | owner: `asda.comm`, 23 | isValid: false, 24 | }, 25 | "Valid Email": { 26 | owner: `gmail@gmail.com`, 27 | isValid: true, 28 | }, 29 | "Invalid Team": { 30 | owner: `@org/`, 31 | isValid: false, 32 | }, 33 | "Valid Team": { 34 | owner: `@org/user`, 35 | isValid: true, 36 | }, 37 | "Invalid User": { 38 | owner: `user`, 39 | isValid: false, 40 | }, 41 | "Valid User": { 42 | owner: `@user`, 43 | isValid: true, 44 | }, 45 | } 46 | for tn, tc := range tests { 47 | t.Run(tn, func(t *testing.T) { 48 | // when 49 | result := check.IsValidOwner(tc.owner) 50 | assert.Equal(t, tc.isValid, result) 51 | }) 52 | } 53 | } 54 | 55 | func TestValidOwnerCheckerIgnoredOwner(t *testing.T) { 56 | t.Run("Should ignore owner", func(t *testing.T) { 57 | // given 58 | ownerCheck, err := check.NewValidOwner(check.ValidOwnerConfig{ 59 | Repository: "org/repo", 60 | IgnoredOwners: []string{"@owner1"}, 61 | }, nil, true) 62 | require.NoError(t, err) 63 | 64 | givenCodeowners := `* @owner1` 65 | 66 | // when 67 | out, err := ownerCheck.Check(context.Background(), LoadInput(givenCodeowners)) 68 | 69 | // then 70 | require.NoError(t, err) 71 | assert.Empty(t, out.Issues) 72 | }) 73 | 74 | t.Run("Should ignore user only and check the remaining owners", func(t *testing.T) { 75 | tests := map[string]struct { 76 | codeowners string 77 | issue *check.Issue 78 | allowUnownedPatterns bool 79 | }{ 80 | "No owners": { 81 | codeowners: `*`, 82 | issue: &check.Issue{ 83 | Severity: check.Warning, 84 | LineNo: ptr.Uint64Ptr(1), 85 | Message: "Missing owner, at least one owner is required", 86 | }, 87 | }, 88 | "Bad owner definition": { 89 | codeowners: `* badOwner @owner1`, 90 | issue: &check.Issue{ 91 | Severity: check.Error, 92 | LineNo: ptr.Uint64Ptr(1), 93 | Message: `Not valid owner definition "badOwner"`, 94 | }, 95 | }, 96 | "No owners but allow empty": { 97 | codeowners: `*`, 98 | issue: nil, 99 | allowUnownedPatterns: true, 100 | }, 101 | } 102 | for tn, tc := range tests { 103 | t.Run(tn, func(t *testing.T) { 104 | // given 105 | ownerCheck, err := check.NewValidOwner(check.ValidOwnerConfig{ 106 | Repository: "org/repo", 107 | AllowUnownedPatterns: tc.allowUnownedPatterns, 108 | IgnoredOwners: []string{"@owner1"}, 109 | }, nil, true) 110 | require.NoError(t, err) 111 | 112 | // when 113 | out, err := ownerCheck.Check(context.Background(), LoadInput(tc.codeowners)) 114 | 115 | // then 116 | require.NoError(t, err) 117 | assertIssue(t, tc.issue, out.Issues) 118 | }) 119 | } 120 | }) 121 | } 122 | 123 | func TestValidOwnerCheckerOwnersMustBeTeams(t *testing.T) { 124 | tests := map[string]struct { 125 | codeowners string 126 | issue *check.Issue 127 | allowUnownedPatterns bool 128 | }{ 129 | "Bad owner definition": { 130 | codeowners: `* @owner1`, 131 | issue: &check.Issue{ 132 | Severity: check.Error, 133 | LineNo: ptr.Uint64Ptr(1), 134 | Message: `Only team owners allowed and "@owner1" is not a team`, 135 | }, 136 | }, 137 | "No owners but allow empty": { 138 | codeowners: `*`, 139 | issue: nil, 140 | allowUnownedPatterns: true, 141 | }, 142 | } 143 | for tn, tc := range tests { 144 | t.Run(tn, func(t *testing.T) { 145 | // given 146 | ownerCheck, err := check.NewValidOwner(check.ValidOwnerConfig{ 147 | Repository: "org/repo", 148 | AllowUnownedPatterns: tc.allowUnownedPatterns, 149 | OwnersMustBeTeams: true, 150 | }, nil, true) 151 | require.NoError(t, err) 152 | 153 | // when 154 | out, err := ownerCheck.Check(context.Background(), LoadInput(tc.codeowners)) 155 | 156 | // then 157 | require.NoError(t, err) 158 | assertIssue(t, tc.issue, out.Issues) 159 | }) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /internal/check/valid_syntax.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "go.szostok.io/codeowners-validator/internal/ctxutil" 10 | ) 11 | 12 | var ( 13 | // A valid username/organization name has up to 39 characters (per GitHub Join page) 14 | // and is matched by the following regex: /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i 15 | // A valid team name consists of alphanumerics, underscores and dashes 16 | usernameOrTeamRegexp = regexp.MustCompile(`^@(?i:[a-z\d](?:[a-z\d_-]){0,37}[a-z\d](/[a-z\d](?:[a-z\d_-]*)[a-z\d])?)$`) 17 | 18 | // Per: https://davidcel.is/posts/stop-validating-email-addresses-with-regex/ 19 | // just check if there is '@' and a '.' afterwards 20 | emailRegexp = regexp.MustCompile(`.+@.+\..+`) 21 | ) 22 | 23 | // ValidSyntax provides a syntax validation for CODEOWNERS file. 24 | // 25 | // If any line in your CODEOWNERS file contains invalid syntax, the file will not be detected and will 26 | // not be used to request reviews. Invalid syntax includes inline comments and user or team names that do not exist on GitHub. 27 | type ValidSyntax struct{} 28 | 29 | // NewValidSyntax returns new ValidSyntax instance. 30 | func NewValidSyntax() *ValidSyntax { 31 | return &ValidSyntax{} 32 | } 33 | 34 | // Check for syntax issues in your CODEOWNERS file. 35 | func (v *ValidSyntax) Check(ctx context.Context, in Input) (Output, error) { 36 | var bldr OutputBuilder 37 | 38 | for _, entry := range in.CodeownersEntries { 39 | if ctxutil.ShouldExit(ctx) { 40 | return Output{}, ctx.Err() 41 | } 42 | 43 | if entry.Pattern == "" { 44 | bldr.ReportIssue("Missing pattern", WithEntry(entry)) 45 | } 46 | 47 | ownersLoop: 48 | for _, item := range entry.Owners { 49 | switch { 50 | case strings.EqualFold(item, "#"): 51 | break ownersLoop // no need to check for the rest items in this line, as they are ignored 52 | case strings.HasPrefix(item, "@"): 53 | if !usernameOrTeamRegexp.MatchString(item) { 54 | msg := fmt.Sprintf("Owner '%s' does not look like a GitHub username or team name", item) 55 | bldr.ReportIssue(msg, WithEntry(entry), WithSeverity(Warning)) 56 | } 57 | default: 58 | if !emailRegexp.MatchString(item) { 59 | msg := fmt.Sprintf("Owner '%s' does not look like an email", item) 60 | bldr.ReportIssue(msg, WithEntry(entry)) 61 | } 62 | } 63 | } 64 | } 65 | 66 | return bldr.Output(), nil 67 | } 68 | 69 | func (ValidSyntax) Name() string { 70 | return "Valid Syntax Checker" 71 | } 72 | -------------------------------------------------------------------------------- /internal/check/valid_syntax_test.go: -------------------------------------------------------------------------------- 1 | package check_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "go.szostok.io/codeowners-validator/internal/check" 8 | "go.szostok.io/codeowners-validator/internal/ptr" 9 | "go.szostok.io/codeowners-validator/pkg/codeowners" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestValidSyntaxChecker(t *testing.T) { 16 | tests := map[string]struct { 17 | codeowners string 18 | issue *check.Issue 19 | }{ 20 | "No owners": { 21 | codeowners: `*`, 22 | issue: nil, 23 | }, 24 | "Bad username": { 25 | codeowners: `pkg/github.com/** @-`, 26 | issue: &check.Issue{ 27 | Severity: check.Warning, 28 | LineNo: ptr.Uint64Ptr(1), 29 | Message: "Owner '@-' does not look like a GitHub username or team name", 30 | }, 31 | }, 32 | "Bad org": { 33 | codeowners: `* @bad+org`, 34 | issue: &check.Issue{ 35 | Severity: check.Warning, 36 | LineNo: ptr.Uint64Ptr(1), 37 | Message: "Owner '@bad+org' does not look like a GitHub username or team name", 38 | }, 39 | }, 40 | "Bad team name on first place": { 41 | codeowners: `* @org/+not+a+good+name`, 42 | issue: &check.Issue{ 43 | Severity: check.Warning, 44 | LineNo: ptr.Uint64Ptr(1), 45 | Message: "Owner '@org/+not+a+good+name' does not look like a GitHub username or team name", 46 | }, 47 | }, 48 | "Bad team name on second place": { 49 | codeowners: `* @org/hakuna-matata @org/-a-team`, 50 | issue: &check.Issue{ 51 | Severity: check.Warning, 52 | LineNo: ptr.Uint64Ptr(1), 53 | Message: "Owner '@org/-a-team' does not look like a GitHub username or team name", 54 | }, 55 | }, 56 | "Doesn't look like username, team name, nor email": { 57 | codeowners: `* something_weird`, 58 | issue: &check.Issue{ 59 | Severity: check.Error, 60 | LineNo: ptr.Uint64Ptr(1), 61 | Message: "Owner 'something_weird' does not look like an email", 62 | }, 63 | }, 64 | "Comment in pattern line": { 65 | codeowners: `* @org/hakuna-matata # this is allowed`, 66 | }, 67 | } 68 | for tn, tc := range tests { 69 | t.Run(tn, func(t *testing.T) { 70 | // when 71 | out, err := check.NewValidSyntax(). 72 | Check(context.Background(), LoadInput(tc.codeowners)) 73 | 74 | // then 75 | require.NoError(t, err) 76 | 77 | assertIssue(t, tc.issue, out.Issues) 78 | }) 79 | } 80 | } 81 | 82 | func TestValidSyntaxZeroValueEntry(t *testing.T) { 83 | // given 84 | zeroValueInput := check.Input{ 85 | CodeownersEntries: []codeowners.Entry{ 86 | { 87 | LineNo: 0, 88 | Pattern: "", 89 | Owners: nil, 90 | }, 91 | }, 92 | } 93 | expIssues := []check.Issue{ 94 | { 95 | LineNo: ptr.Uint64Ptr(0), 96 | Severity: check.Error, 97 | Message: "Missing pattern", 98 | }, 99 | } 100 | 101 | // when 102 | out, err := check.NewValidSyntax(). 103 | Check(context.Background(), zeroValueInput) 104 | 105 | // then 106 | require.NoError(t, err) 107 | 108 | require.Len(t, out.Issues, len(expIssues)) 109 | assert.EqualValues(t, expIssues, out.Issues) 110 | } 111 | -------------------------------------------------------------------------------- /internal/ctxutil/check.go: -------------------------------------------------------------------------------- 1 | package ctxutil 2 | 3 | import "context" 4 | 5 | func ShouldExit(ctx context.Context) bool { 6 | select { 7 | case <-ctx.Done(): 8 | return true 9 | default: 10 | return false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/ctxutil/check_test.go: -------------------------------------------------------------------------------- 1 | package ctxutil_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | contextutil "go.szostok.io/codeowners-validator/internal/ctxutil" 9 | ) 10 | 11 | func TestShouldExit(t *testing.T) { 12 | t.Run("Should notify about exit if context is canceled", func(t *testing.T) { 13 | // given 14 | ctx, cancel := context.WithCancel(context.Background()) 15 | 16 | // when 17 | cancel() 18 | shouldExit := contextutil.ShouldExit(ctx) 19 | 20 | // then 21 | assert.True(t, shouldExit) 22 | }) 23 | 24 | t.Run("Should return false if context is not canceled", func(t *testing.T) { 25 | // given 26 | ctx := context.Background() 27 | 28 | // when 29 | shouldExit := contextutil.ShouldExit(ctx) 30 | 31 | // then 32 | assert.False(t, shouldExit) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /internal/envconfig/envconfig.go: -------------------------------------------------------------------------------- 1 | package envconfig 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/vrischmann/envconfig" 7 | ) 8 | 9 | // Init the given config. Supports also envs prefix if set. 10 | func Init(conf interface{}) error { 11 | envPrefix := os.Getenv("ENVS_PREFIX") 12 | return envconfig.InitWithPrefix(conf, envPrefix) 13 | } 14 | -------------------------------------------------------------------------------- /internal/envconfig/envconfig_test.go: -------------------------------------------------------------------------------- 1 | package envconfig_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "go.szostok.io/codeowners-validator/internal/envconfig" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gotest.tools/assert" 11 | ) 12 | 13 | type testConfig struct { 14 | Key1 string 15 | } 16 | 17 | func TestInit(t *testing.T) { 18 | t.Run("Should read env variable without prefix", func(t *testing.T) { 19 | // given 20 | var cfg testConfig 21 | 22 | require.NoError(t, os.Setenv("KEY1", "test-value")) 23 | 24 | // when 25 | err := envconfig.Init(&cfg) 26 | 27 | // then 28 | require.NoError(t, err) 29 | assert.Equal(t, "test-value", cfg.Key1) 30 | }) 31 | 32 | t.Run("Should read env variable with prefix", func(t *testing.T) { 33 | // given 34 | var cfg testConfig 35 | 36 | require.NoError(t, os.Setenv("ENVS_PREFIX", "TEST_PREFIX")) 37 | require.NoError(t, os.Setenv("TEST_PREFIX_KEY1", "test-value")) 38 | 39 | // when 40 | err := envconfig.Init(&cfg) 41 | 42 | // then 43 | require.NoError(t, err) 44 | assert.Equal(t, "test-value", cfg.Key1) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /internal/github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/bradleyfalzon/ghinstallation/v2" 10 | 11 | "go.szostok.io/codeowners-validator/pkg/url" 12 | 13 | "github.com/google/go-github/v41/github" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | type ClientConfig struct { 18 | AccessToken string `envconfig:"optional"` 19 | 20 | AppID int64 `envconfig:"optional"` 21 | AppPrivateKey string `envconfig:"optional"` 22 | AppInstallationID int64 `envconfig:"optional"` 23 | 24 | BaseURL string `envconfig:"optional"` 25 | UploadURL string `envconfig:"optional"` 26 | HTTPRequestTimeout time.Duration `envconfig:"default=30s"` 27 | } 28 | 29 | // Validate validates if provided client options are valid. 30 | func (c *ClientConfig) Validate() error { 31 | if c.AccessToken == "" && c.AppID == 0 { 32 | return errors.New("GitHub authorization is required, provide ACCESS_TOKEN or APP_ID") 33 | } 34 | 35 | if c.AccessToken != "" && c.AppID != 0 { 36 | return errors.New("GitHub ACCESS_TOKEN cannot be provided when APP_ID is specified") 37 | } 38 | 39 | if c.AppID != 0 { 40 | if c.AppInstallationID == 0 { 41 | return errors.New("GitHub APP_INSTALLATION_ID is required with APP_ID") 42 | } 43 | if c.AppPrivateKey == "" { 44 | return errors.New("GitHub APP_PRIVATE_KEY is required with APP_ID") 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func NewClient(ctx context.Context, cfg *ClientConfig) (ghClient *github.Client, isApp bool, err error) { 52 | if err := cfg.Validate(); err != nil { 53 | return nil, false, err 54 | } 55 | 56 | httpClient := http.DefaultClient 57 | 58 | if cfg.AccessToken != "" { 59 | httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource( 60 | &oauth2.Token{AccessToken: cfg.AccessToken}, 61 | )) 62 | } else if cfg.AppID != 0 { 63 | httpClient, err = createAppInstallationHTTPClient(cfg) 64 | isApp = true 65 | if err != nil { 66 | return 67 | } 68 | } 69 | httpClient.Timeout = cfg.HTTPRequestTimeout 70 | 71 | baseURL, uploadURL := cfg.BaseURL, cfg.UploadURL 72 | 73 | if baseURL == "" { 74 | ghClient = github.NewClient(httpClient) 75 | return 76 | } 77 | 78 | if uploadURL == "" { // often the baseURL is same as the uploadURL, so we do not require to provide both of them 79 | uploadURL = baseURL 80 | } 81 | 82 | bURL, uURL := url.CanonicalPath(baseURL), url.CanonicalPath(uploadURL) 83 | ghClient, err = github.NewEnterpriseClient(bURL, uURL, httpClient) 84 | return 85 | } 86 | 87 | func createAppInstallationHTTPClient(cfg *ClientConfig) (client *http.Client, err error) { 88 | tr := http.DefaultTransport 89 | itr, err := ghinstallation.New(tr, cfg.AppID, cfg.AppInstallationID, []byte(cfg.AppPrivateKey)) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | return &http.Client{Transport: itr}, nil 95 | } 96 | -------------------------------------------------------------------------------- /internal/load/load.go: -------------------------------------------------------------------------------- 1 | package load 2 | 3 | import ( 4 | "context" 5 | 6 | "go.szostok.io/codeowners-validator/internal/check" 7 | "go.szostok.io/codeowners-validator/internal/envconfig" 8 | "go.szostok.io/codeowners-validator/internal/github" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // For now, it is a good enough solution to init checks. Important thing is to do not require env variables 14 | // and do not create clients which will not be used because of the given checker. 15 | // 16 | // MAYBE in the future the https://github.com/uber-go/dig will be used. 17 | func Checks(ctx context.Context, enabledChecks, experimentalChecks []string) ([]check.Checker, error) { 18 | var checks []check.Checker 19 | 20 | if isEnabled(enabledChecks, "syntax") { 21 | checks = append(checks, check.NewValidSyntax()) 22 | } 23 | 24 | if isEnabled(enabledChecks, "duppatterns") { 25 | checks = append(checks, check.NewDuplicatedPattern()) 26 | } 27 | 28 | if isEnabled(enabledChecks, "files") { 29 | checks = append(checks, check.NewFileExist()) 30 | } 31 | 32 | if isEnabled(enabledChecks, "owners") { 33 | var cfg struct { 34 | OwnerChecker check.ValidOwnerConfig 35 | Github github.ClientConfig 36 | } 37 | if err := envconfig.Init(&cfg); err != nil { 38 | return nil, errors.Wrapf(err, "while loading config for %s", "owners") 39 | } 40 | 41 | ghClient, isApp, err := github.NewClient(ctx, &cfg.Github) 42 | if err != nil { 43 | return nil, errors.Wrap(err, "while creating GitHub client") 44 | } 45 | 46 | owners, err := check.NewValidOwner(cfg.OwnerChecker, ghClient, !isApp) 47 | if err != nil { 48 | return nil, errors.Wrap(err, "while enabling 'owners' checker") 49 | } 50 | 51 | if err := owners.CheckSatisfied(ctx); err != nil { 52 | return nil, errors.Wrap(err, "while checking if 'owners' checker is satisfied") 53 | } 54 | 55 | checks = append(checks, owners) 56 | } 57 | 58 | expChecks, err := loadExperimentalChecks(experimentalChecks) 59 | if err != nil { 60 | return nil, errors.Wrap(err, "while loading experimental checks") 61 | } 62 | 63 | return append(checks, expChecks...), nil 64 | } 65 | 66 | func loadExperimentalChecks(experimentalChecks []string) ([]check.Checker, error) { 67 | var checks []check.Checker 68 | 69 | if contains(experimentalChecks, "notowned") { 70 | var cfg struct { 71 | NotOwnedChecker check.NotOwnedFileConfig 72 | } 73 | if err := envconfig.Init(&cfg); err != nil { 74 | return nil, errors.Wrapf(err, "while loading config for %s", "notowned") 75 | } 76 | 77 | checks = append(checks, check.NewNotOwnedFile(cfg.NotOwnedChecker)) 78 | } 79 | 80 | if contains(experimentalChecks, "avoid-shadowing") { 81 | checks = append(checks, check.NewAvoidShadowing()) 82 | } 83 | 84 | return checks, nil 85 | } 86 | 87 | func isEnabled(checks []string, name string) bool { 88 | // if a user does not specify concrete checks then all checks are enabled 89 | if len(checks) == 0 { 90 | return true 91 | } 92 | 93 | if contains(checks, name) { 94 | return true 95 | } 96 | return false 97 | } 98 | 99 | func contains(checks []string, name string) bool { 100 | for _, c := range checks { 101 | if c == name { 102 | return true 103 | } 104 | } 105 | return false 106 | } 107 | -------------------------------------------------------------------------------- /internal/printer/testdata/TestTTYPrinterPrintCheckResult/Should_print_OK_status_on_empty_issues_list.golden.txt: -------------------------------------------------------------------------------- 1 | ==> Executing Foo Checker (1s) 2 | Check OK 3 | -------------------------------------------------------------------------------- /internal/printer/testdata/TestTTYPrinterPrintCheckResult/Should_print_all_reported_issues.golden.txt: -------------------------------------------------------------------------------- 1 | ==> Executing Foo Checker (1s) 2 | [err] line 42: Simulate error in line 42 3 | [war] line 2020: Simulate warning in line 2020 4 | [err] Error without line number 5 | [war] Warning without line number 6 | [Internal Error] some check internal error 7 | -------------------------------------------------------------------------------- /internal/printer/testdata/TestTTYPrinterPrintSummary/Should_print_no_when_there_is_no_failures.golden.txt: -------------------------------------------------------------------------------- 1 | 2 | 20 check(s) executed, no failure(s) 3 | -------------------------------------------------------------------------------- /internal/printer/testdata/TestTTYPrinterPrintSummary/Should_print_number_of_failures.golden.txt: -------------------------------------------------------------------------------- 1 | 2 | 20 check(s) executed, 10 failure(s) 3 | -------------------------------------------------------------------------------- /internal/printer/tty.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/fatih/color" 12 | "go.szostok.io/codeowners-validator/internal/check" 13 | ) 14 | 15 | // writer used for test purpose 16 | var writer io.Writer = os.Stdout 17 | 18 | type TTYPrinter struct { 19 | m sync.RWMutex 20 | } 21 | 22 | func (tty *TTYPrinter) PrintCheckResult(checkName string, duration time.Duration, checkOut check.Output, checkErr error) { 23 | tty.m.Lock() 24 | defer tty.m.Unlock() 25 | 26 | header := color.New(color.Bold).FprintfFunc() 27 | issueBody := color.New(color.FgWhite).FprintfFunc() 28 | okCheck := color.New(color.FgGreen).FprintlnFunc() 29 | errCheck := color.New(color.FgRed).FprintfFunc() 30 | 31 | header(writer, "==> Executing %s (%v)\n", checkName, duration) 32 | for _, i := range checkOut.Issues { 33 | issueSeverity := tty.severityPrintfFunc(i.Severity) 34 | 35 | issueSeverity(writer, " [%s]", strings.ToLower(i.Severity.String()[:3])) 36 | if i.LineNo != nil { 37 | issueBody(writer, " line %d:", *i.LineNo) 38 | } 39 | issueBody(writer, " %s\n", i.Message) 40 | } 41 | 42 | switch { 43 | case checkErr == nil && len(checkOut.Issues) == 0: 44 | okCheck(writer, " Check OK") 45 | case checkErr != nil: 46 | errCheck(writer, " [Internal Error]") 47 | issueBody(writer, " %s\n", checkErr) 48 | } 49 | } 50 | 51 | func (*TTYPrinter) severityPrintfFunc(severity check.SeverityType) func(w io.Writer, format string, a ...interface{}) { 52 | p := color.New() 53 | switch severity { 54 | case check.Warning: 55 | p.Add(color.FgYellow) 56 | case check.Error: 57 | p.Add(color.FgRed) 58 | } 59 | 60 | return p.FprintfFunc() 61 | } 62 | 63 | func (*TTYPrinter) PrintSummary(allCheck, failedChecks int) { 64 | failures := "no" 65 | if failedChecks > 0 { 66 | failures = fmt.Sprintf("%d", failedChecks) 67 | } 68 | fmt.Fprintf(writer, "\n%d check(s) executed, %s failure(s)\n", allCheck, failures) 69 | } 70 | -------------------------------------------------------------------------------- /internal/printer/tty_test.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "testing" 8 | "time" 9 | 10 | "go.szostok.io/codeowners-validator/internal/check" 11 | "go.szostok.io/codeowners-validator/internal/ptr" 12 | 13 | "github.com/sebdah/goldie/v2" 14 | ) 15 | 16 | func TestTTYPrinterPrintCheckResult(t *testing.T) { 17 | t.Run("Should print all reported issues", func(t *testing.T) { 18 | // given 19 | tty := TTYPrinter{} 20 | 21 | buff := &bytes.Buffer{} 22 | restore := overrideWriter(buff) 23 | defer restore() 24 | 25 | // when 26 | tty.PrintCheckResult("Foo Checker", time.Second, check.Output{ 27 | Issues: []check.Issue{ 28 | { 29 | Severity: check.Error, 30 | LineNo: ptr.Uint64Ptr(42), 31 | Message: "Simulate error in line 42", 32 | }, 33 | { 34 | Severity: check.Warning, 35 | LineNo: ptr.Uint64Ptr(2020), 36 | Message: "Simulate warning in line 2020", 37 | }, 38 | { 39 | Severity: check.Error, 40 | Message: "Error without line number", 41 | }, 42 | { 43 | Severity: check.Warning, 44 | Message: "Warning without line number", 45 | }, 46 | }, 47 | }, errors.New("some check internal error")) 48 | // then 49 | g := goldie.New(t, goldie.WithNameSuffix(".golden.txt")) 50 | g.Assert(t, t.Name(), buff.Bytes()) 51 | }) 52 | 53 | t.Run("Should print OK status on empty issues list", func(t *testing.T) { 54 | // given 55 | tty := TTYPrinter{} 56 | 57 | buff := &bytes.Buffer{} 58 | restore := overrideWriter(buff) 59 | defer restore() 60 | 61 | // when 62 | tty.PrintCheckResult("Foo Checker", time.Second, check.Output{ 63 | Issues: nil, 64 | }, nil) 65 | 66 | // then 67 | g := goldie.New(t, goldie.WithNameSuffix(".golden.txt")) 68 | g.Assert(t, t.Name(), buff.Bytes()) 69 | }) 70 | } 71 | 72 | func TestTTYPrinterPrintSummary(t *testing.T) { 73 | t.Run("Should print number of failures", func(t *testing.T) { 74 | // given 75 | tty := TTYPrinter{} 76 | 77 | buff := &bytes.Buffer{} 78 | restore := overrideWriter(buff) 79 | defer restore() 80 | 81 | // when 82 | tty.PrintSummary(20, 10) 83 | 84 | // then 85 | g := goldie.New(t, goldie.WithNameSuffix(".golden.txt")) 86 | g.Assert(t, t.Name(), buff.Bytes()) 87 | }) 88 | 89 | t.Run("Should print no when there is no failures", func(t *testing.T) { 90 | // given 91 | tty := TTYPrinter{} 92 | 93 | buff := &bytes.Buffer{} 94 | restore := overrideWriter(buff) 95 | defer restore() 96 | 97 | // when 98 | tty.PrintSummary(20, 0) 99 | 100 | // then 101 | g := goldie.New(t, goldie.WithNameSuffix(".golden.txt")) 102 | g.Assert(t, t.Name(), buff.Bytes()) 103 | }) 104 | } 105 | 106 | func overrideWriter(in io.Writer) func() { 107 | old := writer 108 | writer = in 109 | return func() { writer = old } 110 | } 111 | -------------------------------------------------------------------------------- /internal/ptr/uint.go: -------------------------------------------------------------------------------- 1 | package ptr 2 | 3 | func Uint64Ptr(u uint64) *uint64 { 4 | c := u 5 | return &c 6 | } 7 | -------------------------------------------------------------------------------- /internal/runner/runner_worker.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "go.szostok.io/codeowners-validator/internal/check" 9 | "go.szostok.io/codeowners-validator/internal/printer" 10 | "go.szostok.io/codeowners-validator/pkg/codeowners" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | const ( 16 | // MaxUint defines the max unsigned int value. 17 | MaxUint = ^uint(0) 18 | // MaxInt defines the max signed int value. 19 | MaxInt = int(MaxUint >> 1) 20 | ) 21 | 22 | // Printer prints the checks results 23 | type Printer interface { 24 | PrintCheckResult(checkName string, duration time.Duration, checkOut check.Output, err error) 25 | PrintSummary(allCheck int, failedChecks int) 26 | } 27 | 28 | // CheckRunner runs all registered checks in parallel. 29 | // Needs to be initialized via NewCheckRunner func. 30 | type CheckRunner struct { 31 | m sync.RWMutex 32 | log logrus.FieldLogger 33 | codeowners []codeowners.Entry 34 | repoPath string 35 | treatedAsFailure check.SeverityType 36 | checks []check.Checker 37 | printer Printer 38 | allFoundIssues map[check.SeverityType]uint32 39 | notPassedChecksCnt int 40 | } 41 | 42 | // NewCheckRunner is a constructor for CheckRunner 43 | func NewCheckRunner(log logrus.FieldLogger, co []codeowners.Entry, repoPath string, treatedAsFailure check.SeverityType, checks ...check.Checker) *CheckRunner { 44 | return &CheckRunner{ 45 | log: log.WithField("service", "check:runner"), 46 | repoPath: repoPath, 47 | treatedAsFailure: treatedAsFailure, 48 | codeowners: co, 49 | checks: checks, 50 | 51 | printer: &printer.TTYPrinter{}, 52 | allFoundIssues: map[check.SeverityType]uint32{}, 53 | } 54 | } 55 | 56 | // Run executes given test in a loop with given throttle 57 | func (r *CheckRunner) Run(ctx context.Context) { 58 | wg := sync.WaitGroup{} 59 | 60 | // TODO(mszostok): timeout per check? 61 | wg.Add(len(r.checks)) 62 | for _, c := range r.checks { 63 | go func(c check.Checker) { 64 | defer wg.Done() 65 | startTime := time.Now() 66 | out, err := c.Check(ctx, check.Input{ 67 | CodeownersEntries: r.codeowners, 68 | RepoDir: r.repoPath, 69 | }) 70 | 71 | r.collectMetrics(out, err) 72 | r.printer.PrintCheckResult(c.Name(), time.Since(startTime), out, err) 73 | }(c) 74 | } 75 | wg.Wait() 76 | 77 | r.printer.PrintSummary(len(r.checks), r.notPassedChecksCnt) 78 | } 79 | 80 | func (r *CheckRunner) ShouldExitWithCheckFailure() bool { 81 | higherOccurredIssue := check.SeverityType(MaxInt) 82 | for key := range r.allFoundIssues { 83 | if higherOccurredIssue > key { 84 | higherOccurredIssue = key 85 | } 86 | } 87 | 88 | return higherOccurredIssue <= r.treatedAsFailure 89 | } 90 | 91 | func (r *CheckRunner) collectMetrics(checkOut check.Output, err error) { 92 | r.m.Lock() 93 | defer r.m.Unlock() 94 | for _, i := range checkOut.Issues { 95 | r.allFoundIssues[i.Severity]++ 96 | } 97 | 98 | if err != nil { 99 | r.allFoundIssues[check.Error]++ 100 | } 101 | 102 | if len(checkOut.Issues) > 0 || err != nil { 103 | r.notPassedChecksCnt++ 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "path/filepath" 8 | "syscall" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | "go.szostok.io/version/extension" 13 | 14 | "go.szostok.io/codeowners-validator/internal/check" 15 | "go.szostok.io/codeowners-validator/internal/envconfig" 16 | "go.szostok.io/codeowners-validator/internal/load" 17 | "go.szostok.io/codeowners-validator/internal/runner" 18 | "go.szostok.io/codeowners-validator/pkg/codeowners" 19 | ) 20 | 21 | // Config holds the application configuration 22 | type Config struct { 23 | RepositoryPath string 24 | CheckFailureLevel check.SeverityType `envconfig:"default=warning"` 25 | Checks []string `envconfig:"optional"` 26 | ExperimentalChecks []string `envconfig:"optional"` 27 | } 28 | 29 | func main() { 30 | ctx, cancelFunc := WithStopContext(context.Background()) 31 | defer cancelFunc() 32 | 33 | if err := NewRoot().ExecuteContext(ctx); err != nil { 34 | // error is already handled by `cobra`, we don't want to log it here as we will duplicate the message. 35 | // If needed, based on error type we can exit with different codes. 36 | //nolint:gocritic 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | func exitOnError(err error) { 42 | if err != nil { 43 | logrus.Fatal(err) 44 | } 45 | } 46 | 47 | // WithStopContext returns a copy of parent with a new Done channel. The returned 48 | // context's Done channel is closed on of SIGINT or SIGTERM signals. 49 | func WithStopContext(parent context.Context) (context.Context, context.CancelFunc) { 50 | ctx, cancel := context.WithCancel(parent) 51 | 52 | sigCh := make(chan os.Signal, 1) 53 | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 54 | go func() { 55 | select { 56 | case <-ctx.Done(): 57 | case <-sigCh: 58 | cancel() 59 | } 60 | }() 61 | 62 | return ctx, cancel 63 | } 64 | 65 | // NewRoot returns a root cobra.Command for the whole Agent utility. 66 | func NewRoot() *cobra.Command { 67 | rootCmd := &cobra.Command{ 68 | Use: "codeowners-validator", 69 | Short: "Ensures the correctness of your CODEOWNERS file.", 70 | SilenceUsage: true, 71 | Run: func(cmd *cobra.Command, args []string) { 72 | var cfg Config 73 | err := envconfig.Init(&cfg) 74 | exitOnError(err) 75 | 76 | log := logrus.New() 77 | 78 | // init checks 79 | checks, err := load.Checks(cmd.Context(), cfg.Checks, cfg.ExperimentalChecks) 80 | exitOnError(err) 81 | 82 | // init codeowners entries 83 | codeownersEntries, err := codeowners.NewFromPath(cfg.RepositoryPath) 84 | exitOnError(err) 85 | 86 | // run check runner 87 | absRepoPath, err := filepath.Abs(cfg.RepositoryPath) 88 | exitOnError(err) 89 | 90 | checkRunner := runner.NewCheckRunner(log, codeownersEntries, absRepoPath, cfg.CheckFailureLevel, checks...) 91 | checkRunner.Run(cmd.Context()) 92 | 93 | if cmd.Context().Err() != nil { 94 | log.Error("Application was interrupted by operating system") 95 | os.Exit(2) 96 | } 97 | if checkRunner.ShouldExitWithCheckFailure() { 98 | os.Exit(3) 99 | } 100 | }, 101 | } 102 | 103 | rootCmd.AddCommand( 104 | extension.NewVersionCobraCmd(), 105 | ) 106 | 107 | return rootCmd 108 | } 109 | -------------------------------------------------------------------------------- /pkg/codeowners/export_test.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import "github.com/spf13/afero" 4 | 5 | func SetFS(newFs afero.Fs) func() { 6 | oldFS := fs 7 | fs = newFs 8 | 9 | revert := func() { 10 | fs = oldFS 11 | } 12 | 13 | return revert 14 | } 15 | -------------------------------------------------------------------------------- /pkg/codeowners/owners.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/dustin/go-humanize/english" 12 | "github.com/spf13/afero" 13 | ) 14 | 15 | // Used for testing purposes 16 | var fs = afero.NewOsFs() 17 | 18 | // Entry contains owners for a given pattern 19 | type Entry struct { 20 | LineNo uint64 21 | Pattern string 22 | Owners []string 23 | } 24 | 25 | func (e Entry) String() string { 26 | return fmt.Sprintf("line %d: %s\t%v", e.LineNo, e.Pattern, strings.Join(e.Owners, ", ")) 27 | } 28 | 29 | // NewFromPath returns entries from codeowners 30 | func NewFromPath(repoPath string) ([]Entry, error) { 31 | r, err := openCodeownersFile(repoPath) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return ParseCodeowners(r), nil 37 | } 38 | 39 | // openCodeownersFile finds a CODEOWNERS file and returns content. 40 | // see: https://help.github.com/articles/about-code-owners/#codeowners-file-location 41 | func openCodeownersFile(dir string) (io.Reader, error) { 42 | var detectedFiles []string 43 | for _, p := range []string{".", "docs", ".github"} { 44 | pth := path.Join(dir, p) 45 | exists, err := afero.DirExists(fs, pth) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | if !exists { 51 | continue 52 | } 53 | 54 | f := path.Join(pth, "CODEOWNERS") 55 | _, err = fs.Stat(f) 56 | switch { 57 | case err == nil: 58 | case os.IsNotExist(err): 59 | continue 60 | default: 61 | return nil, err 62 | } 63 | 64 | detectedFiles = append(detectedFiles, f) 65 | } 66 | 67 | switch l := len(detectedFiles); l { 68 | case 0: 69 | return nil, fmt.Errorf("No CODEOWNERS found in the root, docs/, or .github/ directory of the repository %s", dir) 70 | case 1: 71 | return fs.Open(detectedFiles[0]) 72 | default: 73 | return nil, fmt.Errorf("Multiple CODEOWNERS files found in the %s locations of the repository %s", 74 | english.OxfordWordSeries(replacePrefix(detectedFiles, dir, "./"), "and"), 75 | dir) 76 | } 77 | } 78 | 79 | func replacePrefix(in []string, prefix, s string) []string { 80 | for idx := range in { 81 | in[idx] = fmt.Sprintf("%s%s", s, strings.TrimPrefix(in[idx], prefix)) 82 | } 83 | return in 84 | } 85 | 86 | func ParseCodeowners(r io.Reader) []Entry { 87 | var e []Entry 88 | s := bufio.NewScanner(r) 89 | no := uint64(0) 90 | for s.Scan() { 91 | no++ 92 | fields := strings.Fields(s.Text()) 93 | 94 | if len(fields) == 0 { // empty 95 | continue 96 | } 97 | 98 | if strings.HasPrefix(fields[0], "#") { // comment 99 | continue 100 | } 101 | 102 | n := len(fields) 103 | for idx, x := range fields { 104 | if !strings.HasPrefix(x, "#") { 105 | continue 106 | } 107 | n = idx 108 | } 109 | 110 | e = append(e, Entry{ 111 | Pattern: fields[0], 112 | Owners: fields[1:n], 113 | LineNo: no, 114 | }) 115 | } 116 | 117 | return e 118 | } 119 | -------------------------------------------------------------------------------- /pkg/codeowners/owners_example_test.go: -------------------------------------------------------------------------------- 1 | package codeowners_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.szostok.io/codeowners-validator/pkg/codeowners" 7 | ) 8 | 9 | func ExampleNewFromPath() { 10 | pathToCodeownersFile := "./testdata/" 11 | 12 | entries, err := codeowners.NewFromPath(pathToCodeownersFile) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | for _, e := range entries { 18 | fmt.Printf("[line] %d: [pattern]: %s [owners]: %v\n", e.LineNo, e.Pattern, e.Owners) 19 | } 20 | 21 | // Output: 22 | // [line] 8: [pattern]: * [owners]: [@global-owner1 @global-owner2] 23 | // [line] 14: [pattern]: *.js [owners]: [@js-owner] 24 | // [line] 19: [pattern]: *.go [owners]: [docs@example.com] 25 | // [line] 24: [pattern]: /build/logs/ [owners]: [@doctocat] 26 | // [line] 29: [pattern]: docs/* [owners]: [docs@example.com] 27 | // [line] 33: [pattern]: apps/ [owners]: [@octocat] 28 | // [line] 37: [pattern]: /docs/ [owners]: [@doctocat] 29 | } 30 | -------------------------------------------------------------------------------- /pkg/codeowners/owners_test.go: -------------------------------------------------------------------------------- 1 | package codeowners_test 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "testing" 7 | 8 | "github.com/spf13/afero" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "go.szostok.io/codeowners-validator/pkg/codeowners" 12 | ) 13 | 14 | const sampleCodeownerFile = ` 15 | # Sample codeowner file 16 | * @everyone 17 | 18 | src/** @org/hakuna-matata @pico-bello 19 | pkg/github.com/** @myk 20 | tests/** @ghost # some comment 21 | internal/** @ghost #some comment v2 22 | 23 | ` 24 | 25 | func TestParseCodeownersSuccess(t *testing.T) { 26 | // given 27 | givenCodeownerPath := "workspace/go/repo-name" 28 | expEntries := []codeowners.Entry{ 29 | { 30 | LineNo: 3, 31 | Pattern: "*", 32 | Owners: []string{"@everyone"}, 33 | }, 34 | { 35 | LineNo: 5, 36 | Pattern: "src/**", 37 | Owners: []string{"@org/hakuna-matata", "@pico-bello"}, 38 | }, 39 | { 40 | LineNo: 6, 41 | Pattern: "pkg/github.com/**", 42 | Owners: []string{"@myk"}, 43 | }, 44 | { 45 | LineNo: 7, 46 | Pattern: "tests/**", 47 | Owners: []string{"@ghost"}, 48 | }, 49 | { 50 | LineNo: 8, 51 | Pattern: "internal/**", 52 | Owners: []string{"@ghost"}, 53 | }, 54 | } 55 | 56 | tFS := afero.NewMemMapFs() 57 | revert := codeowners.SetFS(tFS) 58 | defer revert() 59 | 60 | f, _ := tFS.Create(path.Join(givenCodeownerPath, "CODEOWNERS")) 61 | _, err := f.WriteString(sampleCodeownerFile) 62 | require.NoError(t, err) 63 | 64 | // when 65 | entries, err := codeowners.NewFromPath(givenCodeownerPath) 66 | 67 | // then 68 | require.NoError(t, err) 69 | assert.Len(t, entries, len(expEntries)) 70 | for _, expEntry := range expEntries { 71 | assert.Contains(t, entries, expEntry) 72 | } 73 | } 74 | 75 | func TestFindCodeownersFileSuccess(t *testing.T) { 76 | tests := map[string]struct { 77 | basePath string 78 | }{ 79 | "Should find the CODEOWNERS at root": { 80 | basePath: "/workspace/go/repo-name1/", 81 | }, 82 | "Should find the CODEOWNERS in docs/": { 83 | basePath: "/workspace/go/repo-name2/docs/", 84 | }, 85 | "Should find the CODEOWNERS IN .github": { 86 | basePath: "/workspace/go/repo-name3/.github/", 87 | }, 88 | "Should manage situation without trailing slash": { 89 | basePath: "/workspace/go/repo-name3/.github", 90 | }, 91 | } 92 | for tn, tc := range tests { 93 | t.Run(tn, func(t *testing.T) { 94 | // given 95 | tFS := afero.NewMemMapFs() 96 | revert := codeowners.SetFS(tFS) 97 | defer revert() 98 | 99 | _, err := tFS.Create(path.Join(tc.basePath, "CODEOWNERS")) 100 | require.NoError(t, err) 101 | 102 | // when 103 | entry, err := codeowners.NewFromPath(tc.basePath) 104 | 105 | // then 106 | require.NoError(t, err) 107 | require.Empty(t, entry) 108 | }) 109 | } 110 | } 111 | 112 | func TestFindCodeownersFileFailure(t *testing.T) { 113 | // given 114 | tFS := afero.NewMemMapFs() 115 | revert := codeowners.SetFS(tFS) 116 | defer revert() 117 | 118 | givenRepoPath := "/workspace/go/repo-without-codeowners/" 119 | expErrMsg := fmt.Sprintf("No CODEOWNERS found in the root, docs/, or .github/ directory of the repository %s", givenRepoPath) 120 | 121 | // when 122 | entries, err := codeowners.NewFromPath(givenRepoPath) 123 | 124 | // then 125 | assert.EqualError(t, err, expErrMsg) 126 | assert.Nil(t, entries) 127 | } 128 | 129 | func TestMultipleCodeownersFileFailure(t *testing.T) { 130 | const givenRepoPath = "/workspace/go/repo-without-codeowners/" 131 | tests := map[string]struct { 132 | expErrMsg string 133 | givenCodeownersLocations []string 134 | }{ 135 | "Should report that no CODEOWNERS file was found": { 136 | expErrMsg: fmt.Sprintf("No CODEOWNERS found in the root, docs/, or .github/ directory of the repository %s", givenRepoPath), 137 | givenCodeownersLocations: nil, 138 | }, 139 | "Should report that CODEOWNERS file was found on root and docs/": { 140 | expErrMsg: fmt.Sprintf("Multiple CODEOWNERS files found in the ./CODEOWNERS and ./docs/CODEOWNERS locations of the repository %s", givenRepoPath), 141 | givenCodeownersLocations: []string{"CODEOWNERS", path.Join("docs", "CODEOWNERS")}, 142 | }, 143 | "Should report that CODEOWNERS file was found on root and .github/": { 144 | expErrMsg: fmt.Sprintf("Multiple CODEOWNERS files found in the ./CODEOWNERS and ./.github/CODEOWNERS locations of the repository %s", givenRepoPath), 145 | givenCodeownersLocations: []string{"CODEOWNERS", path.Join(".github/", "CODEOWNERS")}, 146 | }, 147 | "Should report that CODEOWNERS file was found in docs/ and .github/": { 148 | expErrMsg: fmt.Sprintf("Multiple CODEOWNERS files found in the ./docs/CODEOWNERS and ./.github/CODEOWNERS locations of the repository %s", givenRepoPath), 149 | givenCodeownersLocations: []string{path.Join(".github", "CODEOWNERS"), path.Join("docs", "CODEOWNERS")}, 150 | }, 151 | "Should report that CODEOWNERS file was found on root, docs/ and .github/": { 152 | expErrMsg: fmt.Sprintf("Multiple CODEOWNERS files found in the ./CODEOWNERS, ./docs/CODEOWNERS, and ./.github/CODEOWNERS locations of the repository %s", givenRepoPath), 153 | givenCodeownersLocations: []string{"CODEOWNERS", path.Join(".github", "CODEOWNERS"), path.Join("docs", "CODEOWNERS")}, 154 | }, 155 | } 156 | for tn, tc := range tests { 157 | t.Run(tn, func(t *testing.T) { 158 | // given 159 | tFS := afero.NewMemMapFs() 160 | revert := codeowners.SetFS(tFS) 161 | defer revert() 162 | 163 | for _, location := range tc.givenCodeownersLocations { 164 | _, err := tFS.Create(path.Join(givenRepoPath, location)) 165 | require.NoError(t, err) 166 | } 167 | 168 | // when 169 | entries, err := codeowners.NewFromPath(givenRepoPath) 170 | 171 | // then 172 | assert.EqualError(t, err, tc.expErrMsg) 173 | assert.Nil(t, entries) 174 | }) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /pkg/codeowners/testdata/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in 5 | # the repo. Unless a later match takes precedence, 6 | # @global-owner1 and @global-owner2 will be requested for 7 | # review when someone opens a pull request. 8 | * @global-owner1 @global-owner2 9 | 10 | # Order is important; the last matching pattern takes the most 11 | # precedence. When someone opens a pull request that only 12 | # modifies JS files, only @js-owner and not the global 13 | # owner(s) will be requested for a review. 14 | *.js @js-owner 15 | 16 | # You can also use email addresses if you prefer. They'll be 17 | # used to look up users just like we do for commit author 18 | # emails. 19 | *.go docs@example.com 20 | 21 | # In this example, @doctocat owns any files in the build/logs 22 | # directory at the root of the repository and any of its 23 | # subdirectories. 24 | /build/logs/ @doctocat 25 | 26 | # The `docs/*` pattern will match files like 27 | # `docs/getting-started.md` but not further nested files like 28 | # `docs/build-app/troubleshooting.md`. 29 | docs/* docs@example.com 30 | 31 | # In this example, @octocat owns any file in an apps directory 32 | # anywhere in your repository. 33 | apps/ @octocat 34 | 35 | # In this example, @doctocat owns any file in the `/docs` 36 | # directory in the root of your repository. 37 | /docs/ @doctocat -------------------------------------------------------------------------------- /pkg/url/canonical.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func CanonicalPath(path string) string { 8 | normalized := strings.TrimRight(path, "/") 9 | if !strings.HasSuffix(normalized, "/") { 10 | normalized += "/" 11 | } 12 | return normalized 13 | } 14 | -------------------------------------------------------------------------------- /pkg/url/canonical_test.go: -------------------------------------------------------------------------------- 1 | package url_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "go.szostok.io/codeowners-validator/pkg/url" 8 | ) 9 | 10 | func TestCanonicalURLPath(t *testing.T) { 11 | tests := map[string]struct { 12 | givenPath string 13 | expPath string 14 | }{ 15 | "no trailing slash": { 16 | givenPath: "https://api.github.com", 17 | expPath: "https://api.github.com/", 18 | }, 19 | "multiple trailing slashes": { 20 | givenPath: "https://api.github.com///////////////", 21 | expPath: "https://api.github.com/", 22 | }, 23 | "single trailing slash": { 24 | givenPath: "https://api.github.com/", 25 | expPath: "https://api.github.com/", 26 | }, 27 | } 28 | for tn, tc := range tests { 29 | t.Run(tn, func(t *testing.T) { 30 | // when 31 | normalizedPath := url.CanonicalPath(tc.givenPath) 32 | 33 | // then 34 | assert.Equal(t, tc.expPath, normalizedPath) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/integration/helpers_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package integration 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "io/ioutil" 10 | "os" 11 | "os/exec" 12 | "regexp" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "github.com/go-git/go-git/v5" 18 | "github.com/go-git/go-git/v5/plumbing" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | func normalizeTimeDurations(in string) string { 23 | duration := regexp.MustCompile(`\(\d+(\.\d+)?(ns|us|µs|ms|s|m|h)\)`) 24 | return duration.ReplaceAllString(in, "()") 25 | } 26 | 27 | func normalizeLogTime(in string) string { 28 | duration := regexp.MustCompile(`time="[^"]*"`) 29 | return duration.ReplaceAllString(in, `time="