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 |
5 |
Ensures the correctness of your CODEOWNERS file.
6 |
7 |
8 |
9 |
10 |
11 |
12 | ## Codeowners Validator
13 |
14 |
15 |
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 | 
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:
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 |
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 |
8 |
9 |
10 |
11 | ##
12 | The [Codeowners Validator](https://github.com/mszostok/codeowners-validator) is available as a GitHub Action.
13 |
14 |
15 |
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 | 
24 |
25 | #### Private repositories
26 |
27 | For private repositories, select `repo` and `read:org`:
28 |
29 | 
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="