├── .github ├── CODEOWNERS ├── DISCUSSION_TEMPLATE │ └── users.yml ├── dependabot.yml └── workflows │ ├── build-and-test.yaml │ ├── cargo-deny.yaml │ └── pre-commit.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── CODE_OF_CONDUCT.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── OWNERS ├── README.md ├── deny.toml ├── rust-toolchain └── src ├── allow_filter.rs ├── codeowners.rs ├── main.rs ├── owners_file.rs ├── owners_set.rs ├── owners_tree.rs ├── pipeline.rs └── test_utils.rs /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # AUTO GENERATED FILE 3 | # Do Not Manually Update 4 | # For details, see: 5 | # https://github.com/andrewring/github-distributed-owners#readme 6 | ################################################################################ 7 | 8 | * @andrewring 9 | 10 | ################################################################################ 11 | # AUTO GENERATED FILE 12 | # Do Not Manually Update 13 | # For details, see: 14 | # https://github.com/andrewring/github-distributed-owners#readme 15 | ################################################################################ 16 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/users.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: markdown 3 | attributes: 4 | value: | 5 | # Thank You For Sharing With Us! 6 | Thank you for taking the time to share about your use with us. Whether you love it (:tada:) or hate it 7 | (:disappointed:), feedback is how we improve. 8 | - type: input 9 | id: who 10 | attributes: 11 | label: Who are you? 12 | description: Company, team, project, or person, who is using github-distributed-owners? 13 | placeholder: Company, team, project, or person 14 | validations: 15 | required: true 16 | - type: dropdown 17 | id: impression 18 | attributes: 19 | label: What do you think? 20 | description: Do you love it, hate it, etc? 21 | options: 22 | - "💙 I love it" 23 | - "😕 It's complicated" 24 | - "🥱 It's... fine. Meh" 25 | - "😰 I hate it" 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: thoughts 30 | attributes: 31 | label: Thoughts 32 | description: Care to elaborate? 33 | validations: 34 | required: false 35 | - type: checkboxes 36 | id: logo 37 | attributes: 38 | label: Can we display your logo as a user? 39 | options: 40 | - label: "Yes" 41 | validations: 42 | required: false 43 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions-rs/toolchain@v1 15 | - uses: actions-rs/cargo@v1 16 | with: 17 | command: build 18 | args: --release --all-features 19 | test: 20 | name: Test 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: actions-rs/toolchain@v1 25 | - uses: actions-rs/cargo@v1 26 | with: 27 | command: test 28 | args: --all-features 29 | -------------------------------------------------------------------------------- /.github/workflows/cargo-deny.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | name: cargo-deny 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | checks: 15 | - advisories 16 | - bans licenses sources 17 | 18 | # Don't fail CI for advisories 19 | continue-on-error: ${{ matrix.checks == 'advisories' }} 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: EmbarkStudios/cargo-deny-action@v2 24 | with: 25 | rust-version: '1.70.0' 26 | command: check ${{ matrix.checks }} 27 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | - uses: actions-rs/toolchain@v1 15 | with: 16 | components: rustfmt, clippy 17 | - uses: pre-commit/action@v3.0.0 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Generated by Cargo 4 | # will have compiled files and executables 5 | debug/ 6 | target/ 7 | 8 | # These are backup files generated by rustfmt 9 | **/*.rs.bk 10 | 11 | # MSVC Windows builds of rustc generate these, which store debugging information 12 | *.pdb 13 | 14 | 15 | # Added by cargo 16 | 17 | /target 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/andrewring/github-distributed-owners 3 | rev: 072dd1552b699bdc92232f05634961cbb6f4f4fc 4 | hooks: 5 | - id: github-distributed-owners 6 | - repo: https://github.com/doublify/pre-commit-rust 7 | rev: v1.0 8 | hooks: 9 | - id: fmt 10 | - id: cargo-check 11 | - id: clippy 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.4.0 14 | hooks: 15 | - id: check-added-large-files 16 | - id: check-merge-conflict 17 | - id: check-symlinks 18 | - id: check-toml 19 | - id: check-yaml 20 | - id: detect-private-key 21 | - id: end-of-file-fixer 22 | - id: trailing-whitespace 23 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: github-distributed-owners 2 | name: GitHub Distributed Owners 3 | description: Auto generate a GitHub compatible CODEOWNERS files from OWNERS files distributed through the file tree. 4 | entry: github-distributed-owners 5 | language: rust 6 | types: [file, text] 7 | args: ["--output-file=.github/CODEOWNERS"] 8 | pass_filenames: false 9 | require_serial: true 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | github-distributed-owners-team@googlegroups.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "0f2135563fb5c609d2b2b87c1e8ce7bc41b0b45430fa9661f457981503dd5bf0" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anyhow" 16 | version = "1.0.75" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" 19 | 20 | [[package]] 21 | name = "atty" 22 | version = "0.2.14" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 25 | dependencies = [ 26 | "hermit-abi 0.1.19", 27 | "libc", 28 | "winapi", 29 | ] 30 | 31 | [[package]] 32 | name = "autocfg" 33 | version = "1.1.0" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 36 | 37 | [[package]] 38 | name = "bitflags" 39 | version = "1.3.2" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 42 | 43 | [[package]] 44 | name = "bitflags" 45 | version = "2.4.0" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" 48 | 49 | [[package]] 50 | name = "cfg-if" 51 | version = "1.0.0" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 54 | 55 | [[package]] 56 | name = "clap" 57 | version = "3.2.25" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" 60 | dependencies = [ 61 | "atty", 62 | "bitflags 1.3.2", 63 | "clap_derive", 64 | "clap_lex", 65 | "indexmap", 66 | "once_cell", 67 | "strsim", 68 | "termcolor", 69 | "textwrap", 70 | ] 71 | 72 | [[package]] 73 | name = "clap-verbosity-flag" 74 | version = "1.0.1" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "0636f9c040082f8e161555a305f8cec1a1c2828b3d981c812b8c39f4ac00c42c" 77 | dependencies = [ 78 | "clap", 79 | "log", 80 | ] 81 | 82 | [[package]] 83 | name = "clap_derive" 84 | version = "3.2.25" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" 87 | dependencies = [ 88 | "heck", 89 | "proc-macro-error", 90 | "proc-macro2", 91 | "quote", 92 | "syn", 93 | ] 94 | 95 | [[package]] 96 | name = "clap_lex" 97 | version = "0.2.4" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" 100 | dependencies = [ 101 | "os_str_bytes", 102 | ] 103 | 104 | [[package]] 105 | name = "either" 106 | version = "1.9.0" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" 109 | 110 | [[package]] 111 | name = "env_logger" 112 | version = "0.10.0" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" 115 | dependencies = [ 116 | "humantime", 117 | "is-terminal", 118 | "log", 119 | "regex", 120 | "termcolor", 121 | ] 122 | 123 | [[package]] 124 | name = "errno" 125 | version = "0.3.8" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" 128 | dependencies = [ 129 | "libc", 130 | "windows-sys 0.52.0", 131 | ] 132 | 133 | [[package]] 134 | name = "fastrand" 135 | version = "2.0.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" 138 | 139 | [[package]] 140 | name = "github-distributed-owners" 141 | version = "0.1.10" 142 | dependencies = [ 143 | "anyhow", 144 | "clap", 145 | "clap-verbosity-flag", 146 | "env_logger", 147 | "indoc", 148 | "itertools", 149 | "lazy_static", 150 | "log", 151 | "regex", 152 | "tempfile", 153 | "textwrap", 154 | ] 155 | 156 | [[package]] 157 | name = "hashbrown" 158 | version = "0.12.3" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 161 | 162 | [[package]] 163 | name = "heck" 164 | version = "0.4.1" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 167 | 168 | [[package]] 169 | name = "hermit-abi" 170 | version = "0.1.19" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 173 | dependencies = [ 174 | "libc", 175 | ] 176 | 177 | [[package]] 178 | name = "hermit-abi" 179 | version = "0.3.3" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" 182 | 183 | [[package]] 184 | name = "humantime" 185 | version = "2.1.0" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 188 | 189 | [[package]] 190 | name = "indexmap" 191 | version = "1.9.3" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 194 | dependencies = [ 195 | "autocfg", 196 | "hashbrown", 197 | ] 198 | 199 | [[package]] 200 | name = "indoc" 201 | version = "2.0.4" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" 204 | 205 | [[package]] 206 | name = "is-terminal" 207 | version = "0.4.9" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" 210 | dependencies = [ 211 | "hermit-abi 0.3.3", 212 | "rustix", 213 | "windows-sys 0.48.0", 214 | ] 215 | 216 | [[package]] 217 | name = "itertools" 218 | version = "0.11.0" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" 221 | dependencies = [ 222 | "either", 223 | ] 224 | 225 | [[package]] 226 | name = "lazy_static" 227 | version = "1.4.0" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 230 | 231 | [[package]] 232 | name = "libc" 233 | version = "0.2.151" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" 236 | 237 | [[package]] 238 | name = "linux-raw-sys" 239 | version = "0.4.12" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" 242 | 243 | [[package]] 244 | name = "log" 245 | version = "0.4.20" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 248 | 249 | [[package]] 250 | name = "memchr" 251 | version = "2.6.3" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" 254 | 255 | [[package]] 256 | name = "once_cell" 257 | version = "1.18.0" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" 260 | 261 | [[package]] 262 | name = "os_str_bytes" 263 | version = "6.5.1" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" 266 | 267 | [[package]] 268 | name = "proc-macro-error" 269 | version = "1.0.4" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 272 | dependencies = [ 273 | "proc-macro-error-attr", 274 | "proc-macro2", 275 | "quote", 276 | "syn", 277 | "version_check", 278 | ] 279 | 280 | [[package]] 281 | name = "proc-macro-error-attr" 282 | version = "1.0.4" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 285 | dependencies = [ 286 | "proc-macro2", 287 | "quote", 288 | "version_check", 289 | ] 290 | 291 | [[package]] 292 | name = "proc-macro2" 293 | version = "1.0.67" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" 296 | dependencies = [ 297 | "unicode-ident", 298 | ] 299 | 300 | [[package]] 301 | name = "quote" 302 | version = "1.0.33" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 305 | dependencies = [ 306 | "proc-macro2", 307 | ] 308 | 309 | [[package]] 310 | name = "redox_syscall" 311 | version = "0.3.5" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" 314 | dependencies = [ 315 | "bitflags 1.3.2", 316 | ] 317 | 318 | [[package]] 319 | name = "regex" 320 | version = "1.9.5" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" 323 | dependencies = [ 324 | "aho-corasick", 325 | "memchr", 326 | "regex-automata", 327 | "regex-syntax", 328 | ] 329 | 330 | [[package]] 331 | name = "regex-automata" 332 | version = "0.3.8" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" 335 | dependencies = [ 336 | "aho-corasick", 337 | "memchr", 338 | "regex-syntax", 339 | ] 340 | 341 | [[package]] 342 | name = "regex-syntax" 343 | version = "0.7.5" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" 346 | 347 | [[package]] 348 | name = "rustix" 349 | version = "0.38.28" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" 352 | dependencies = [ 353 | "bitflags 2.4.0", 354 | "errno", 355 | "libc", 356 | "linux-raw-sys", 357 | "windows-sys 0.52.0", 358 | ] 359 | 360 | [[package]] 361 | name = "smawk" 362 | version = "0.3.2" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 365 | 366 | [[package]] 367 | name = "strsim" 368 | version = "0.10.0" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 371 | 372 | [[package]] 373 | name = "syn" 374 | version = "1.0.109" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 377 | dependencies = [ 378 | "proc-macro2", 379 | "quote", 380 | "unicode-ident", 381 | ] 382 | 383 | [[package]] 384 | name = "tempfile" 385 | version = "3.8.0" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" 388 | dependencies = [ 389 | "cfg-if", 390 | "fastrand", 391 | "redox_syscall", 392 | "rustix", 393 | "windows-sys 0.48.0", 394 | ] 395 | 396 | [[package]] 397 | name = "termcolor" 398 | version = "1.3.0" 399 | source = "registry+https://github.com/rust-lang/crates.io-index" 400 | checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64" 401 | dependencies = [ 402 | "winapi-util", 403 | ] 404 | 405 | [[package]] 406 | name = "textwrap" 407 | version = "0.16.0" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" 410 | dependencies = [ 411 | "smawk", 412 | "unicode-linebreak", 413 | "unicode-width", 414 | ] 415 | 416 | [[package]] 417 | name = "unicode-ident" 418 | version = "1.0.12" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 421 | 422 | [[package]] 423 | name = "unicode-linebreak" 424 | version = "0.1.5" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 427 | 428 | [[package]] 429 | name = "unicode-width" 430 | version = "0.1.11" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" 433 | 434 | [[package]] 435 | name = "version_check" 436 | version = "0.9.4" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 439 | 440 | [[package]] 441 | name = "winapi" 442 | version = "0.3.9" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 445 | dependencies = [ 446 | "winapi-i686-pc-windows-gnu", 447 | "winapi-x86_64-pc-windows-gnu", 448 | ] 449 | 450 | [[package]] 451 | name = "winapi-i686-pc-windows-gnu" 452 | version = "0.4.0" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 455 | 456 | [[package]] 457 | name = "winapi-util" 458 | version = "0.1.5" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 461 | dependencies = [ 462 | "winapi", 463 | ] 464 | 465 | [[package]] 466 | name = "winapi-x86_64-pc-windows-gnu" 467 | version = "0.4.0" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 470 | 471 | [[package]] 472 | name = "windows-sys" 473 | version = "0.48.0" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 476 | dependencies = [ 477 | "windows-targets 0.48.5", 478 | ] 479 | 480 | [[package]] 481 | name = "windows-sys" 482 | version = "0.52.0" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 485 | dependencies = [ 486 | "windows-targets 0.52.0", 487 | ] 488 | 489 | [[package]] 490 | name = "windows-targets" 491 | version = "0.48.5" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 494 | dependencies = [ 495 | "windows_aarch64_gnullvm 0.48.5", 496 | "windows_aarch64_msvc 0.48.5", 497 | "windows_i686_gnu 0.48.5", 498 | "windows_i686_msvc 0.48.5", 499 | "windows_x86_64_gnu 0.48.5", 500 | "windows_x86_64_gnullvm 0.48.5", 501 | "windows_x86_64_msvc 0.48.5", 502 | ] 503 | 504 | [[package]] 505 | name = "windows-targets" 506 | version = "0.52.0" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" 509 | dependencies = [ 510 | "windows_aarch64_gnullvm 0.52.0", 511 | "windows_aarch64_msvc 0.52.0", 512 | "windows_i686_gnu 0.52.0", 513 | "windows_i686_msvc 0.52.0", 514 | "windows_x86_64_gnu 0.52.0", 515 | "windows_x86_64_gnullvm 0.52.0", 516 | "windows_x86_64_msvc 0.52.0", 517 | ] 518 | 519 | [[package]] 520 | name = "windows_aarch64_gnullvm" 521 | version = "0.48.5" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 524 | 525 | [[package]] 526 | name = "windows_aarch64_gnullvm" 527 | version = "0.52.0" 528 | source = "registry+https://github.com/rust-lang/crates.io-index" 529 | checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" 530 | 531 | [[package]] 532 | name = "windows_aarch64_msvc" 533 | version = "0.48.5" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 536 | 537 | [[package]] 538 | name = "windows_aarch64_msvc" 539 | version = "0.52.0" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" 542 | 543 | [[package]] 544 | name = "windows_i686_gnu" 545 | version = "0.48.5" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 548 | 549 | [[package]] 550 | name = "windows_i686_gnu" 551 | version = "0.52.0" 552 | source = "registry+https://github.com/rust-lang/crates.io-index" 553 | checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" 554 | 555 | [[package]] 556 | name = "windows_i686_msvc" 557 | version = "0.48.5" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 560 | 561 | [[package]] 562 | name = "windows_i686_msvc" 563 | version = "0.52.0" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" 566 | 567 | [[package]] 568 | name = "windows_x86_64_gnu" 569 | version = "0.48.5" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 572 | 573 | [[package]] 574 | name = "windows_x86_64_gnu" 575 | version = "0.52.0" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" 578 | 579 | [[package]] 580 | name = "windows_x86_64_gnullvm" 581 | version = "0.48.5" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 584 | 585 | [[package]] 586 | name = "windows_x86_64_gnullvm" 587 | version = "0.52.0" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" 590 | 591 | [[package]] 592 | name = "windows_x86_64_msvc" 593 | version = "0.48.5" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 596 | 597 | [[package]] 598 | name = "windows_x86_64_msvc" 599 | version = "0.52.0" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" 602 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "github-distributed-owners" 3 | version = "0.1.10" 4 | edition = "2021" 5 | # Version selected to provide compatibility with apt supplied 6 | # rustc from Ubuntu LTS versions. 7 | rust-version = "1.70.0" 8 | authors = ["Andrew Ring"] 9 | 10 | description = "A tool for auto generating GitHub compatible CODEOWNERS files from OWNERS files distributed through the file tree." 11 | readme = "README.md" 12 | homepage = "https://github.com/andrewring/github-distributed-owners" 13 | repository = "https://github.com/andrewring/github-distributed-owners" 14 | license = "MIT" 15 | keywords = ["cli", "devops", "github", "owners", "utility"] 16 | categories = ["command-line-utilities", "development-tools"] 17 | 18 | include = [ 19 | "LICENSE", 20 | "README.md", 21 | "Cargo.toml", 22 | "Cargo.lock", 23 | "**/*.rs" 24 | ] 25 | 26 | [dependencies] 27 | anyhow = "1.0.75" 28 | clap = { version = "3.2.23", features = ["derive"] } 29 | clap-verbosity-flag = "1.0.1" 30 | env_logger = "0.10.0" 31 | indoc = "2.0.4" 32 | itertools = "0.11.0" 33 | lazy_static = "1.4.0" 34 | log = "0.4.20" 35 | regex = "1.9.5" 36 | textwrap = "0.16.0" 37 | 38 | [dev-dependencies] 39 | tempfile = "3.8.0" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Andrew Ring 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | andrewring 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-distributed-owners 2 | 3 | A tool for auto generating GitHub compatible CODEOWNERS files from OWNERS files distributed through the file tree. 4 | 5 | Distributing OWNERS configuration throughout the file tree makes it easier to find the appropriate people/teams who own 6 | a given part of the codebase. This is especially useful in a multi-team, monorepo environment. It also has the nice 7 | property of allowing teams to edit their own OWNERS files, with approval required only from the team. With the single 8 | CODEOWNERS file supported by GitHub, you can either grant _everyone_ access to edit owners, or you can set a smaller 9 | group of reviewers for all teams to send changes to, each of which have problems. 10 | 11 | > [!NOTE] 12 | > If you're using github-distributed-owners, we want to hear from you! 13 | > Please 14 | > [drop us a comment here](https://github.com/andrewring/github-distributed-owners/discussions/new?category=users). 15 | > :) 16 | 17 | ## Usage 18 | 19 | Create files named `OWNERS` in the directories containing newline separated references to users or groups. 20 | 21 | ```shell 22 | github_username 23 | user@email.com 24 | @group 25 | ``` 26 | 27 | Once these are in place, you can generate a GitHub compatible CODEOWNERS file by running the following in the root 28 | directory of the git repo 29 | 30 | ```shell 31 | github-distributed-owners --output-file .github/CODEOWNERS 32 | ``` 33 | 34 | > [!WARNING] 35 | > The generated CODEOWNERS file (`/.github/CODEOWNERS by default) should be set to not have any owners if you are 36 | > enforcing no diff from running this tool. Failure to do so would result in whichever group has ownership of that file 37 | > needing to approve every OWNERS change, which partially defeats the purpose of this process. 38 | > This can be done by adding the following to the OWNERS file adjacent to the CODEOWNERS file, with no owners listed: 39 | > 40 | > ```shell 41 | > [CODEOWNERS] 42 | > set inherit = false 43 | > ``` 44 | 45 | ### Pre-commit 46 | 47 | Example pre-commit config: 48 | 49 | ```yaml 50 | repos: 51 | - repo: https://github.com/andrewring/github-distributed-owners 52 | rev: v0.1.10 53 | hooks: 54 | - id: github-distributed-owners 55 | ``` 56 | 57 | The default CODEOWNERS location is `.github/CODEOWNERS`. This can be changed via 58 | 59 | ```yaml 60 | hooks: 61 | - id: github-distributed-owners 62 | args: [ "--output-file=" ] 63 | ``` 64 | 65 | Note that GitHub will only respect CODEOWNERS files in a small number of locations. See 66 | [the documentation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location) 67 | for details. 68 | 69 | You can further optimize the pre-commit behavior by filtering files processed with hook, like so: 70 | 71 | ```yaml 72 | hooks: 73 | - id: github-distributed-owners 74 | files: (.*/OWNERS|^.github/CODEOWNERS$) 75 | ``` 76 | 77 | NB: The CODEOWNERS path must be updated if specifying the `--output-file`, as above. 78 | 79 | ### Installation 80 | 81 | To install github-distributed-owners independently, 82 | from [crates.io](https://crates.io/crates/github-distributed-owners), 83 | simply run 84 | 85 | ```shell 86 | cargo install github-distributed-owners --locked 87 | ``` 88 | 89 | ## Ownership Inheritance 90 | 91 | By default, owners of directories are automatically included as owners of subdirectories. The default behavior can be 92 | changed by setting `--implicit-inherit false`. For individual directories and patterns, this can be overwritten using 93 | the syntax `set inherit = false`. 94 | 95 | ### Inheritance Example 96 | 97 | ```shell 98 | # /OWNERS 99 | user0 100 | user1 101 | ``` 102 | 103 | ```shell 104 | # /foo/OWNERS 105 | user2 106 | user3 107 | ``` 108 | 109 | ```shell 110 | # /foo/bar/OWNERS 111 | set inherit = false 112 | user4 113 | user5 114 | ``` 115 | 116 | In the above, changes files under `/foo` can be approved by any of `user0`, `user1`, `user2`, `user3`. 117 | Changes to files under `/foo/bar` can only be approved by `user4`, and `user5`, however. 118 | 119 | ## File Patterns 120 | 121 | Where listed users/groups at the top of the file are used to define ownership of all files at the directory level, you 122 | can specify patterns within a directory, as well. This is done by providing the pattern in square brackets, like 123 | `[*.rs]`, with owners and set values after. 124 | 125 | Example: 126 | 127 | ```shell 128 | # Directory level owners 129 | user0 130 | user1 131 | 132 | # Additional owners for rust source files 133 | [*.rs] 134 | user2 135 | user3 136 | 137 | # Separate owners for special files 138 | [special_*] 139 | set inherit = false 140 | user4 141 | user5 142 | ``` 143 | 144 | ## License 145 | 146 | This Action is distributed under the terms of the MIT license, see [LICENSE](LICENSE) for details. 147 | 148 | ## Contribute and support 149 | 150 | Any contributions are welcomed! 151 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # Root options 13 | 14 | # If 1 or more target triples (and optionally, target_features) are specified, 15 | # only the specified targets will be checked when running `cargo deny check`. 16 | # This means, if a particular package is only ever used as a target specific 17 | # dependency, such as, for example, the `nix` crate only being used via the 18 | # `target_family = "unix"` configuration, that only having windows targets in 19 | # this list would mean the nix crate, as well as any of its exclusive 20 | # dependencies not shared by any other crates, would be ignored, as the target 21 | # list here is effectively saying which targets you are building for. 22 | targets = [ 23 | # The triple can be any string, but only the target triples built in to 24 | # rustc (as of 1.40) can be checked against actual config expressions 25 | #{ triple = "x86_64-unknown-linux-musl" }, 26 | # You can also specify which target_features you promise are enabled for a 27 | # particular target. target_features are currently not validated against 28 | # the actual valid features supported by the target architecture. 29 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 30 | ] 31 | # When creating the dependency graph used as the source of truth when checks are 32 | # executed, this field can be used to prune crates from the graph, removing them 33 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 34 | # is pruned from the graph, all of its dependencies will also be pruned unless 35 | # they are connected to another crate in the graph that hasn't been pruned, 36 | # so it should be used with care. The identifiers are [Package ID Specifications] 37 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 38 | #exclude = [] 39 | # If true, metadata will be collected with `--all-features`. Note that this can't 40 | # be toggled off if true, if you want to conditionally enable `--all-features` it 41 | # is recommended to pass `--all-features` on the cmd line instead 42 | all-features = false 43 | # If true, metadata will be collected with `--no-default-features`. The same 44 | # caveat with `all-features` applies 45 | no-default-features = false 46 | # If set, these feature will be enabled when collecting metadata. If `--features` 47 | # is specified on the cmd line they will take precedence over this option. 48 | #features = [] 49 | # When outputting inclusion graphs in diagnostics that include features, this 50 | # option can be used to specify the depth at which feature edges will be added. 51 | # This option is included since the graphs can be quite large and the addition 52 | # of features from the crate(s) to all of the graph roots can be far too verbose. 53 | # This option can be overridden via `--feature-depth` on the cmd line 54 | feature-depth = 1 55 | 56 | # This section is considered when running `cargo deny check advisories` 57 | # More documentation for the advisories section can be found here: 58 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 59 | [advisories] 60 | # The path where the advisory database is cloned/fetched into 61 | db-path = "~/.cargo/advisory-db" 62 | # The url(s) of the advisory databases to use 63 | db-urls = ["https://github.com/rustsec/advisory-db"] 64 | # The lint level for crates that have been yanked from their source registry 65 | yanked = "warn" 66 | # A list of advisory IDs to ignore. Note that ignored advisories will still 67 | # output a note when they are encountered. 68 | ignore = [ 69 | #"RUSTSEC-0000-0000", 70 | ] 71 | # Threshold for security vulnerabilities, any vulnerability with a CVSS score 72 | # lower than the range specified will be ignored. Note that ignored advisories 73 | # will still output a note when they are encountered. 74 | # * None - CVSS Score 0.0 75 | # * Low - CVSS Score 0.1 - 3.9 76 | # * Medium - CVSS Score 4.0 - 6.9 77 | # * High - CVSS Score 7.0 - 8.9 78 | # * Critical - CVSS Score 9.0 - 10.0 79 | #severity-threshold = 80 | 81 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 82 | # If this is false, then it uses a built-in git library. 83 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 84 | # See Git Authentication for more information about setting up git authentication. 85 | #git-fetch-with-cli = true 86 | 87 | # This section is considered when running `cargo deny check licenses` 88 | # More documentation for the licenses section can be found here: 89 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 90 | [licenses] 91 | # List of explicitly allowed licenses 92 | # See https://spdx.org/licenses/ for list of possible licenses 93 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 94 | allow = [ 95 | "Apache-2.0", 96 | "MIT", 97 | "Unicode-DFS-2016", 98 | #"Apache-2.0 WITH LLVM-exception", 99 | ] 100 | # The confidence threshold for detecting a license from license text. 101 | # The higher the value, the more closely the license text must be to the 102 | # canonical license text of a valid SPDX license file. 103 | # [possible values: any between 0.0 and 1.0]. 104 | confidence-threshold = 0.8 105 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 106 | # aren't accepted for every possible crate as with the normal allow list 107 | exceptions = [ 108 | # Each entry is the crate and version constraint, and its specific allow 109 | # list 110 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 111 | ] 112 | 113 | # Some crates don't have (easily) machine readable licensing information, 114 | # adding a clarification entry for it allows you to manually specify the 115 | # licensing information 116 | #[[licenses.clarify]] 117 | # The name of the crate the clarification applies to 118 | #name = "ring" 119 | # The optional version constraint for the crate 120 | #version = "*" 121 | # The SPDX expression for the license requirements of the crate 122 | #expression = "MIT AND ISC AND OpenSSL" 123 | # One or more files in the crate's source used as the "source of truth" for 124 | # the license expression. If the contents match, the clarification will be used 125 | # when running the license check, otherwise the clarification will be ignored 126 | # and the crate will be checked normally, which may produce warnings or errors 127 | # depending on the rest of your configuration 128 | #license-files = [ 129 | # Each entry is a crate relative path, and the (opaque) hash of its contents 130 | #{ path = "LICENSE", hash = 0xbd0eed23 } 131 | #] 132 | 133 | [licenses.private] 134 | # If true, ignores workspace crates that aren't published, or are only 135 | # published to private registries. 136 | # To see how to mark a crate as unpublished (to the official registry), 137 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 138 | ignore = false 139 | # One or more private registries that you might publish crates to, if a crate 140 | # is only published to private registries, and ignore is true, the crate will 141 | # not have its license(s) checked 142 | registries = [ 143 | #"https://sekretz.com/registry 144 | ] 145 | 146 | # This section is considered when running `cargo deny check bans`. 147 | # More documentation about the 'bans' section can be found here: 148 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 149 | [bans] 150 | # Lint level for when multiple versions of the same crate are detected 151 | multiple-versions = "warn" 152 | # Lint level for when a crate version requirement is `*` 153 | wildcards = "allow" 154 | # The graph highlighting used when creating dotgraphs for crates 155 | # with multiple versions 156 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 157 | # * simplest-path - The path to the version with the fewest edges is highlighted 158 | # * all - Both lowest-version and simplest-path are used 159 | highlight = "all" 160 | # The default lint level for `default` features for crates that are members of 161 | # the workspace that is being checked. This can be overridden by allowing/denying 162 | # `default` on a crate-by-crate basis if desired. 163 | workspace-default-features = "allow" 164 | # The default lint level for `default` features for external crates that are not 165 | # members of the workspace. This can be overridden by allowing/denying `default` 166 | # on a crate-by-crate basis if desired. 167 | external-default-features = "allow" 168 | # List of crates that are allowed. Use with care! 169 | allow = [ 170 | #{ name = "ansi_term", version = "=0.11.0" }, 171 | ] 172 | # List of crates to deny 173 | deny = [ 174 | # Each entry the name of a crate and a version range. If version is 175 | # not specified, all versions will be matched. 176 | #{ name = "ansi_term", version = "=0.11.0" }, 177 | # 178 | # Wrapper crates can optionally be specified to allow the crate when it 179 | # is a direct dependency of the otherwise banned crate 180 | #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, 181 | ] 182 | 183 | # List of features to allow/deny 184 | # Each entry the name of a crate and a version range. If version is 185 | # not specified, all versions will be matched. 186 | #[[bans.features]] 187 | #name = "reqwest" 188 | # Features to not allow 189 | #deny = ["json"] 190 | # Features to allow 191 | #allow = [ 192 | # "rustls", 193 | # "__rustls", 194 | # "__tls", 195 | # "hyper-rustls", 196 | # "rustls", 197 | # "rustls-pemfile", 198 | # "rustls-tls-webpki-roots", 199 | # "tokio-rustls", 200 | # "webpki-roots", 201 | #] 202 | # If true, the allowed features must exactly match the enabled feature set. If 203 | # this is set there is no point setting `deny` 204 | #exact = true 205 | 206 | # Certain crates/versions that will be skipped when doing duplicate detection. 207 | skip = [ 208 | { name = "bitflags" } 209 | #{ name = "ansi_term", version = "=0.11.0" }, 210 | ] 211 | # Similarly to `skip` allows you to skip certain crates during duplicate 212 | # detection. Unlike skip, it also includes the entire tree of transitive 213 | # dependencies starting at the specified crate, up to a certain depth, which is 214 | # by default infinite. 215 | skip-tree = [ 216 | #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, 217 | ] 218 | 219 | # This section is considered when running `cargo deny check sources`. 220 | # More documentation about the 'sources' section can be found here: 221 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 222 | [sources] 223 | # Lint level for what to happen when a crate from a crate registry that is not 224 | # in the allow list is encountered 225 | unknown-registry = "warn" 226 | # Lint level for what to happen when a crate from a git repository that is not 227 | # in the allow list is encountered 228 | unknown-git = "warn" 229 | # List of URLs for allowed crate registries. Defaults to the crates.io index 230 | # if not specified. If it is specified but empty, no registries are allowed. 231 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 232 | # List of URLs for allowed Git repositories 233 | allow-git = [] 234 | 235 | [sources.allow-org] 236 | # 1 or more github.com organizations to allow git sources for 237 | # github = [""] 238 | # 1 or more gitlab.com organizations to allow git sources for 239 | # gitlab = [""] 240 | # 1 or more bitbucket.org organizations to allow git sources for 241 | # bitbucket = [""] 242 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.70.0 2 | -------------------------------------------------------------------------------- /src/allow_filter.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use itertools::Itertools; 3 | use log::{trace, warn}; 4 | use std::collections::HashSet; 5 | use std::ffi::OsStr; 6 | use std::path::{Path, PathBuf}; 7 | use std::process::Command; 8 | 9 | pub trait AllowFilter { 10 | fn allowed(&self, path: &Path) -> bool; 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct FilterGitMetadata {} 15 | 16 | impl AllowFilter for FilterGitMetadata { 17 | fn allowed(&self, path: &Path) -> bool { 18 | for component in path { 19 | if component == ".git" { 20 | return false; 21 | } 22 | } 23 | true 24 | } 25 | } 26 | 27 | pub struct AllowList { 28 | allowed_files: HashSet, 29 | _private: (), // Force use of AllowList::from outside this package 30 | } 31 | 32 | impl AllowFilter for AllowList { 33 | fn allowed(&self, path: &Path) -> bool { 34 | self.allowed_files.contains(path) 35 | } 36 | } 37 | 38 | impl AllowList { 39 | pub fn allow_git_files() -> anyhow::Result { 40 | let output = Command::new("git").arg("ls-files").output()?; 41 | if !output.status.success() { 42 | return Err(anyhow!( 43 | "Error gathering git files:\n{}", 44 | String::from_utf8_lossy(&output.stderr) 45 | )); 46 | } 47 | let git_files: HashSet = String::from_utf8_lossy(&output.stdout) 48 | .lines() 49 | .map(PathBuf::from) 50 | // If an OWNERS file has been deleted, but the deletion has not yet been staged, 51 | // an error would be thrown without filtering them out. 52 | .filter(|path| if !path.exists() { 53 | warn!("Missing expected git file at `{}`, possibly the deletion has not been staged?", path.display()); 54 | false 55 | } else { 56 | true 57 | }) 58 | .collect(); 59 | trace!( 60 | "Git files:{}", 61 | git_files 62 | .iter() 63 | .sorted() 64 | .map(|p| format!("\n - {:?}", &p)) 65 | .join("") 66 | ); 67 | AllowList::from(git_files, true) 68 | } 69 | 70 | pub fn from(paths: HashSet, expand: bool) -> anyhow::Result { 71 | let mut expanded_paths: HashSet = HashSet::new(); 72 | for path in paths { 73 | if path.file_name() != Some(OsStr::new("OWNERS")) { 74 | trace!("Ignoring allowed file {:?}, not an OWNERS file", path); 75 | continue; 76 | } 77 | // When walking the file tree, paths are absolute. 78 | // Canonicalize is needed to make these paths to match. 79 | expanded_paths.insert(if expand { 80 | path.canonicalize()? 81 | } else { 82 | path.to_path_buf() 83 | }); 84 | let mut parent = path.parent(); 85 | while let Some(dir) = parent { 86 | if dir.as_os_str().is_empty() { 87 | break; 88 | } 89 | expanded_paths.insert(if expand { 90 | dir.canonicalize()? 91 | } else { 92 | dir.to_path_buf() 93 | }); 94 | parent = dir.parent(); 95 | } 96 | } 97 | Ok(AllowList { 98 | allowed_files: expanded_paths, 99 | _private: (), 100 | }) 101 | } 102 | } 103 | 104 | #[cfg(test)] 105 | mod test { 106 | use crate::allow_filter::{AllowFilter, AllowList, FilterGitMetadata}; 107 | use std::collections::HashSet; 108 | use std::path::{Path, PathBuf}; 109 | 110 | #[test] 111 | fn filter_git_metadata() { 112 | let filter = FilterGitMetadata {}; 113 | assert!(filter.allowed(Path::new("Cargo.lock"))); 114 | assert!(filter.allowed(Path::new("LICENSE"))); 115 | assert!(filter.allowed(Path::new("OWNERS"))); 116 | assert!(filter.allowed(Path::new("src/main.rs"))); 117 | 118 | assert!(!filter.allowed(Path::new(".git/hooks/pre-commit"))); 119 | } 120 | 121 | #[test] 122 | fn allow_list() { 123 | let allowed_files = ["Cargo.lock", "LICENSE", "OWNERS", "src/OWNERS"] 124 | .iter() 125 | .map(PathBuf::from) 126 | .collect::>(); 127 | let filter = AllowList::from(allowed_files, false).unwrap(); 128 | assert!(filter.allowed(Path::new("OWNERS"))); 129 | assert!(filter.allowed(Path::new("src/OWNERS"))); 130 | 131 | // Not OWNERS, so ignored 132 | assert!(!filter.allowed(Path::new("Cargo.lock"))); 133 | assert!(!filter.allowed(Path::new("LICENSE"))); 134 | 135 | // Not included in original allowed_files 136 | assert!(!filter.allowed(Path::new(".git/hooks/pre-commit"))); 137 | assert!(!filter.allowed(Path::new("abc/OWNERS"))); 138 | assert!(!filter.allowed(Path::new("src/main.rs"))); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/codeowners.rs: -------------------------------------------------------------------------------- 1 | use crate::owners_tree::{OwnersTree, TreeNode}; 2 | use itertools::Itertools; 3 | use std::collections::{HashMap, HashSet}; 4 | use std::path::Path; 5 | 6 | pub fn to_codeowners_string(codeowners: HashMap>) -> String { 7 | codeowners 8 | .keys() 9 | .sorted() 10 | .map(|pattern| { 11 | let mut line = pattern.to_string(); 12 | if line == "/" { 13 | // Unlike non-root directories, the repo root directory cannot be used as a catch all path. 14 | // Instead, you have to use `*` at the root directory to achieve the same results. 15 | // https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 16 | line = "*".to_string(); 17 | } 18 | let owners = codeowners 19 | .get(pattern) 20 | .unwrap() 21 | .iter() 22 | .sorted() 23 | .map(|owner| { 24 | // CODEOWNERS syntax can take any of the following formats: 25 | // - @ 26 | // - user@email.tld 27 | // - @org/group 28 | // For non-email versions, we can safely protect against errors 29 | // by prepending an @ 30 | if owner.contains('@') { 31 | owner.to_string() 32 | } else { 33 | format!("@{}", owner) 34 | } 35 | }) 36 | .join(" "); 37 | if !owners.is_empty() { 38 | line = format!("{} {}", line, owners); 39 | } 40 | line 41 | }) 42 | // Don't include a root level owner line if no owners are specified 43 | .filter(|line| line != "*") 44 | .join("\n") 45 | } 46 | 47 | pub fn generate_codeowners( 48 | owners_tree: &OwnersTree, 49 | implicit_inherit: bool, 50 | ) -> anyhow::Result>> { 51 | let mut codeowners = HashMap::new(); 52 | add_codeowners( 53 | owners_tree, 54 | &owners_tree.path, 55 | &HashSet::default(), 56 | implicit_inherit, 57 | &mut codeowners, 58 | )?; 59 | Ok(codeowners) 60 | } 61 | 62 | fn add_codeowners( 63 | tree_node: &TreeNode, 64 | root_path: &Path, 65 | parent_owners: &HashSet, 66 | implicit_inherit: bool, 67 | codeowners: &mut HashMap>, 68 | ) -> anyhow::Result<()> { 69 | let owners_config = &tree_node.owners_config; 70 | let owners_set = &owners_config.all_files; 71 | let mut relative_path = tree_node 72 | .path 73 | .strip_prefix(root_path)? 74 | .to_string_lossy() 75 | .to_string() 76 | + "/"; 77 | // Always use explicit paths from root 78 | if !relative_path.starts_with('/') { 79 | relative_path = format!("/{}", relative_path); 80 | } 81 | 82 | // Gather directory level owners 83 | let mut owners = HashSet::default(); 84 | if owners_set.inherit == Some(true) || (implicit_inherit && owners_set.inherit.is_none()) { 85 | owners.extend(parent_owners.clone()); 86 | } 87 | owners.extend(owners_set.owners.clone()); 88 | 89 | // Add directory level ownership 90 | codeowners.insert(relative_path.clone(), owners.clone()); 91 | 92 | // Add overrides 93 | for (override_pattern, override_owners_set) in &owners_config.pattern_overrides { 94 | let mut override_owners = override_owners_set.owners.clone(); 95 | if override_owners_set.inherit == Some(true) 96 | || implicit_inherit && override_owners_set.inherit.is_none() 97 | { 98 | override_owners.extend(owners.clone()); 99 | } 100 | let mut pattern = relative_path.to_owned(); 101 | pattern.push_str(override_pattern.as_str()); 102 | codeowners.insert(pattern, override_owners); 103 | } 104 | 105 | for child in &tree_node.children { 106 | add_codeowners(child, root_path, &owners, implicit_inherit, codeowners)?; 107 | } 108 | 109 | Ok(()) 110 | } 111 | 112 | #[cfg(test)] 113 | mod test { 114 | use crate::codeowners::{generate_codeowners, to_codeowners_string}; 115 | use crate::owners_file::OwnersFileConfig; 116 | use crate::owners_set::OwnersSet; 117 | use crate::owners_tree::TreeNode; 118 | use indoc::indoc; 119 | use std::collections::{HashMap, HashSet}; 120 | use std::path::PathBuf; 121 | 122 | #[test] 123 | fn generate_codeowners_single_simple() -> anyhow::Result<()> { 124 | let tree_node = TreeNode { 125 | path: PathBuf::from("/tree/root"), 126 | owners_config: OwnersFileConfig { 127 | all_files: OwnersSet { 128 | inherit: None, 129 | owners: vec!["ada.lovelace", "grace.hopper", "margaret.hamilton"] 130 | .iter() 131 | .map(|s| s.to_string()) 132 | .collect::>(), 133 | }, 134 | pattern_overrides: HashMap::default(), 135 | }, 136 | children: Vec::default(), 137 | }; 138 | let implicit_inherit = true; 139 | 140 | let expected = HashMap::from([( 141 | "/".to_string(), 142 | vec!["ada.lovelace", "grace.hopper", "margaret.hamilton"] 143 | .iter() 144 | .map(|s| s.to_string()) 145 | .collect::>(), 146 | )]); 147 | 148 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 149 | 150 | assert_eq!(codeowners, expected); 151 | 152 | Ok(()) 153 | } 154 | 155 | #[test] 156 | fn generate_codeowners_multiple_simple() -> anyhow::Result<()> { 157 | let tree_node = TreeNode { 158 | path: PathBuf::from("/tree/root"), 159 | owners_config: OwnersFileConfig { 160 | all_files: OwnersSet { 161 | inherit: None, 162 | owners: vec!["ada.lovelace", "grace.hopper"] 163 | .iter() 164 | .map(|s| s.to_string()) 165 | .collect::>(), 166 | }, 167 | pattern_overrides: HashMap::default(), 168 | }, 169 | children: vec![TreeNode { 170 | path: PathBuf::from("/tree/root/foo/bar"), 171 | owners_config: OwnersFileConfig { 172 | all_files: OwnersSet { 173 | inherit: None, 174 | owners: vec!["margaret.hamilton", "katherine.johnson"] 175 | .iter() 176 | .map(|s| s.to_string()) 177 | .collect::>(), 178 | }, 179 | pattern_overrides: HashMap::default(), 180 | }, 181 | children: vec![], 182 | }], 183 | }; 184 | let implicit_inherit = true; 185 | 186 | let expected = HashMap::from([ 187 | ( 188 | "/".to_string(), 189 | vec!["ada.lovelace", "grace.hopper"] 190 | .iter() 191 | .map(|s| s.to_string()) 192 | .collect::>(), 193 | ), 194 | ( 195 | "/foo/bar/".to_string(), 196 | vec![ 197 | "ada.lovelace", 198 | "grace.hopper", 199 | "margaret.hamilton", 200 | "katherine.johnson", 201 | ] 202 | .iter() 203 | .map(|s| s.to_string()) 204 | .collect::>(), 205 | ), 206 | ]); 207 | 208 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 209 | 210 | assert_eq!(codeowners, expected); 211 | 212 | Ok(()) 213 | } 214 | 215 | #[test] 216 | fn generate_codeowners_single_with_overrides() -> anyhow::Result<()> { 217 | let tree_node = TreeNode { 218 | path: PathBuf::from("/tree/root"), 219 | owners_config: OwnersFileConfig { 220 | all_files: OwnersSet { 221 | inherit: None, 222 | owners: vec!["ada.lovelace", "grace.hopper"] 223 | .iter() 224 | .map(|s| s.to_string()) 225 | .collect::>(), 226 | }, 227 | pattern_overrides: HashMap::from([( 228 | "*.rs".to_string(), 229 | OwnersSet { 230 | owners: vec!["margaret.hamilton", "katherine.johnson"] 231 | .iter() 232 | .map(|s| s.to_string()) 233 | .collect::>(), 234 | ..OwnersSet::default() 235 | }, 236 | )]), 237 | }, 238 | children: Vec::default(), 239 | }; 240 | let implicit_inherit = true; 241 | 242 | let expected = HashMap::from([ 243 | ( 244 | "/".to_string(), 245 | vec!["ada.lovelace", "grace.hopper"] 246 | .iter() 247 | .map(|s| s.to_string()) 248 | .collect::>(), 249 | ), 250 | ( 251 | "/*.rs".to_string(), 252 | vec![ 253 | "ada.lovelace", 254 | "grace.hopper", 255 | "margaret.hamilton", 256 | "katherine.johnson", 257 | ] 258 | .iter() 259 | .map(|s| s.to_string()) 260 | .collect::>(), 261 | ), 262 | ]); 263 | 264 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 265 | 266 | assert_eq!(codeowners, expected); 267 | 268 | Ok(()) 269 | } 270 | 271 | #[test] 272 | fn generate_codeowners_multiple_with_overrides() -> anyhow::Result<()> { 273 | let tree_node = TreeNode { 274 | path: PathBuf::from("/tree/root"), 275 | owners_config: OwnersFileConfig { 276 | all_files: OwnersSet { 277 | inherit: None, 278 | owners: vec!["ada.lovelace"] 279 | .iter() 280 | .map(|s| s.to_string()) 281 | .collect::>(), 282 | }, 283 | pattern_overrides: HashMap::from([( 284 | "*.rs".to_string(), 285 | OwnersSet { 286 | owners: vec!["margaret.hamilton"] 287 | .iter() 288 | .map(|s| s.to_string()) 289 | .collect::>(), 290 | ..OwnersSet::default() 291 | }, 292 | )]), 293 | }, 294 | children: vec![TreeNode { 295 | path: PathBuf::from("/tree/root/foo/bar"), 296 | owners_config: OwnersFileConfig { 297 | all_files: OwnersSet { 298 | inherit: None, 299 | owners: vec!["grace.hopper"] 300 | .iter() 301 | .map(|s| s.to_string()) 302 | .collect::>(), 303 | }, 304 | pattern_overrides: HashMap::from([( 305 | "*.rs".to_string(), 306 | OwnersSet { 307 | owners: vec!["katherine.johnson"] 308 | .iter() 309 | .map(|s| s.to_string()) 310 | .collect::>(), 311 | ..OwnersSet::default() 312 | }, 313 | )]), 314 | }, 315 | children: vec![], 316 | }], 317 | }; 318 | let implicit_inherit = true; 319 | 320 | let expected = HashMap::from([ 321 | ( 322 | "/".to_string(), 323 | vec!["ada.lovelace"] 324 | .iter() 325 | .map(|s| s.to_string()) 326 | .collect::>(), 327 | ), 328 | ( 329 | "/*.rs".to_string(), 330 | vec!["ada.lovelace", "margaret.hamilton"] 331 | .iter() 332 | .map(|s| s.to_string()) 333 | .collect::>(), 334 | ), 335 | ( 336 | "/foo/bar/".to_string(), 337 | vec!["ada.lovelace", "grace.hopper"] 338 | .iter() 339 | .map(|s| s.to_string()) 340 | .collect::>(), 341 | ), 342 | ( 343 | "/foo/bar/*.rs".to_string(), 344 | vec!["ada.lovelace", "grace.hopper", "katherine.johnson"] 345 | .iter() 346 | .map(|s| s.to_string()) 347 | .collect::>(), 348 | ), 349 | ]); 350 | 351 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 352 | 353 | assert_eq!(codeowners, expected); 354 | 355 | Ok(()) 356 | } 357 | 358 | #[test] 359 | fn generate_codeowners_no_implicit_inherit() -> anyhow::Result<()> { 360 | let tree_node = TreeNode { 361 | path: PathBuf::from("/tree/root"), 362 | owners_config: OwnersFileConfig { 363 | all_files: OwnersSet { 364 | inherit: None, 365 | owners: vec!["ada.lovelace"] 366 | .iter() 367 | .map(|s| s.to_string()) 368 | .collect::>(), 369 | }, 370 | pattern_overrides: HashMap::from([( 371 | "*.rs".to_string(), 372 | OwnersSet { 373 | owners: vec!["margaret.hamilton"] 374 | .iter() 375 | .map(|s| s.to_string()) 376 | .collect::>(), 377 | ..OwnersSet::default() 378 | }, 379 | )]), 380 | }, 381 | children: vec![TreeNode { 382 | path: PathBuf::from("/tree/root/foo/bar"), 383 | owners_config: OwnersFileConfig { 384 | all_files: OwnersSet { 385 | inherit: None, 386 | owners: vec!["grace.hopper"] 387 | .iter() 388 | .map(|s| s.to_string()) 389 | .collect::>(), 390 | }, 391 | pattern_overrides: HashMap::from([( 392 | "*.rs".to_string(), 393 | OwnersSet { 394 | owners: vec!["katherine.johnson"] 395 | .iter() 396 | .map(|s| s.to_string()) 397 | .collect::>(), 398 | ..OwnersSet::default() 399 | }, 400 | )]), 401 | }, 402 | children: vec![], 403 | }], 404 | }; 405 | let implicit_inherit = false; 406 | 407 | let expected = HashMap::from([ 408 | ( 409 | "/".to_string(), 410 | vec!["ada.lovelace"] 411 | .iter() 412 | .map(|s| s.to_string()) 413 | .collect::>(), 414 | ), 415 | ( 416 | "/*.rs".to_string(), 417 | vec!["margaret.hamilton"] 418 | .iter() 419 | .map(|s| s.to_string()) 420 | .collect::>(), 421 | ), 422 | ( 423 | "/foo/bar/".to_string(), 424 | vec!["grace.hopper"] 425 | .iter() 426 | .map(|s| s.to_string()) 427 | .collect::>(), 428 | ), 429 | ( 430 | "/foo/bar/*.rs".to_string(), 431 | vec!["katherine.johnson"] 432 | .iter() 433 | .map(|s| s.to_string()) 434 | .collect::>(), 435 | ), 436 | ]); 437 | 438 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 439 | 440 | assert_eq!(codeowners, expected); 441 | 442 | Ok(()) 443 | } 444 | 445 | #[test] 446 | fn generate_codeowners_selective_inherit() -> anyhow::Result<()> { 447 | let tree_node = TreeNode { 448 | path: PathBuf::from("/tree/root"), 449 | owners_config: OwnersFileConfig { 450 | all_files: OwnersSet { 451 | inherit: None, 452 | owners: vec!["ada.lovelace"] 453 | .iter() 454 | .map(|s| s.to_string()) 455 | .collect::>(), 456 | }, 457 | pattern_overrides: HashMap::from([( 458 | "*.rs".to_string(), 459 | OwnersSet { 460 | owners: vec!["margaret.hamilton"] 461 | .iter() 462 | .map(|s| s.to_string()) 463 | .collect::>(), 464 | inherit: Some(false), 465 | }, 466 | )]), 467 | }, 468 | children: vec![TreeNode { 469 | path: PathBuf::from("/tree/root/foo/bar"), 470 | owners_config: OwnersFileConfig { 471 | all_files: OwnersSet { 472 | inherit: Some(false), 473 | owners: vec!["grace.hopper"] 474 | .iter() 475 | .map(|s| s.to_string()) 476 | .collect::>(), 477 | }, 478 | pattern_overrides: HashMap::from([( 479 | "*.rs".to_string(), 480 | OwnersSet { 481 | owners: vec!["katherine.johnson"] 482 | .iter() 483 | .map(|s| s.to_string()) 484 | .collect::>(), 485 | ..OwnersSet::default() 486 | }, 487 | )]), 488 | }, 489 | children: vec![], 490 | }], 491 | }; 492 | let implicit_inherit = true; 493 | 494 | let expected = HashMap::from([ 495 | ( 496 | "/".to_string(), 497 | vec!["ada.lovelace"] 498 | .iter() 499 | .map(|s| s.to_string()) 500 | .collect::>(), 501 | ), 502 | ( 503 | "/*.rs".to_string(), 504 | vec!["margaret.hamilton"] 505 | .iter() 506 | .map(|s| s.to_string()) 507 | .collect::>(), 508 | ), 509 | ( 510 | "/foo/bar/".to_string(), 511 | vec!["grace.hopper"] 512 | .iter() 513 | .map(|s| s.to_string()) 514 | .collect::>(), 515 | ), 516 | ( 517 | "/foo/bar/*.rs".to_string(), 518 | vec!["grace.hopper", "katherine.johnson"] 519 | .iter() 520 | .map(|s| s.to_string()) 521 | .collect::>(), 522 | ), 523 | ]); 524 | 525 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 526 | 527 | assert_eq!(codeowners, expected); 528 | 529 | Ok(()) 530 | } 531 | 532 | #[test] 533 | fn generate_codeowners_selective_inherit_with_no_implicit() -> anyhow::Result<()> { 534 | let tree_node = TreeNode { 535 | path: PathBuf::from("/tree/root"), 536 | owners_config: OwnersFileConfig { 537 | all_files: OwnersSet { 538 | inherit: None, 539 | owners: vec!["ada.lovelace"] 540 | .iter() 541 | .map(|s| s.to_string()) 542 | .collect::>(), 543 | }, 544 | pattern_overrides: HashMap::from([( 545 | "*.rs".to_string(), 546 | OwnersSet { 547 | owners: vec!["margaret.hamilton"] 548 | .iter() 549 | .map(|s| s.to_string()) 550 | .collect::>(), 551 | inherit: Some(true), 552 | }, 553 | )]), 554 | }, 555 | children: vec![TreeNode { 556 | path: PathBuf::from("/tree/root/foo/bar"), 557 | owners_config: OwnersFileConfig { 558 | all_files: OwnersSet { 559 | inherit: Some(true), 560 | owners: vec!["grace.hopper"] 561 | .iter() 562 | .map(|s| s.to_string()) 563 | .collect::>(), 564 | }, 565 | pattern_overrides: HashMap::from([( 566 | "*.rs".to_string(), 567 | OwnersSet { 568 | owners: vec!["katherine.johnson"] 569 | .iter() 570 | .map(|s| s.to_string()) 571 | .collect::>(), 572 | ..OwnersSet::default() 573 | }, 574 | )]), 575 | }, 576 | children: vec![], 577 | }], 578 | }; 579 | let implicit_inherit = false; 580 | 581 | let expected = HashMap::from([ 582 | ( 583 | "/".to_string(), 584 | vec!["ada.lovelace"] 585 | .iter() 586 | .map(|s| s.to_string()) 587 | .collect::>(), 588 | ), 589 | ( 590 | "/*.rs".to_string(), 591 | vec!["ada.lovelace", "margaret.hamilton"] 592 | .iter() 593 | .map(|s| s.to_string()) 594 | .collect::>(), 595 | ), 596 | ( 597 | "/foo/bar/".to_string(), 598 | vec!["ada.lovelace", "grace.hopper"] 599 | .iter() 600 | .map(|s| s.to_string()) 601 | .collect::>(), 602 | ), 603 | ( 604 | "/foo/bar/*.rs".to_string(), 605 | vec!["katherine.johnson"] 606 | .iter() 607 | .map(|s| s.to_string()) 608 | .collect::>(), 609 | ), 610 | ]); 611 | 612 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 613 | 614 | assert_eq!(codeowners, expected); 615 | 616 | Ok(()) 617 | } 618 | 619 | #[test] 620 | fn generate_codeowners_subdir_without_owners() -> anyhow::Result<()> { 621 | let tree_node = TreeNode { 622 | path: PathBuf::from("/tree/root"), 623 | owners_config: OwnersFileConfig { 624 | all_files: OwnersSet { 625 | inherit: None, 626 | owners: vec!["ada.lovelace", "grace.hopper"] 627 | .iter() 628 | .map(|s| s.to_string()) 629 | .collect::>(), 630 | }, 631 | pattern_overrides: HashMap::default(), 632 | }, 633 | children: vec![TreeNode { 634 | path: PathBuf::from("/tree/root/foo/bar"), 635 | owners_config: OwnersFileConfig { 636 | all_files: OwnersSet { 637 | inherit: Some(false), 638 | owners: HashSet::default(), 639 | }, 640 | pattern_overrides: HashMap::default(), 641 | }, 642 | children: vec![], 643 | }], 644 | }; 645 | let implicit_inherit = true; 646 | 647 | let expected = HashMap::from([ 648 | ( 649 | "/".to_string(), 650 | vec!["ada.lovelace", "grace.hopper"] 651 | .iter() 652 | .map(|s| s.to_string()) 653 | .collect::>(), 654 | ), 655 | ("/foo/bar/".to_string(), HashSet::default()), 656 | ]); 657 | 658 | let codeowners = generate_codeowners(&tree_node, implicit_inherit)?; 659 | 660 | assert_eq!(codeowners, expected); 661 | 662 | Ok(()) 663 | } 664 | 665 | #[test] 666 | fn to_codeowners_string_multilevel() -> anyhow::Result<()> { 667 | let codeowners = HashMap::from([ 668 | ( 669 | "/".to_string(), 670 | vec!["ada.lovelace"] 671 | .iter() 672 | .map(|s| s.to_string()) 673 | .collect::>(), 674 | ), 675 | ( 676 | "/*.rs".to_string(), 677 | vec!["ada.lovelace", "margaret.hamilton"] 678 | .iter() 679 | .map(|s| s.to_string()) 680 | .collect::>(), 681 | ), 682 | ( 683 | "/foo/bar/".to_string(), 684 | vec!["ada.lovelace", "grace.hopper"] 685 | .iter() 686 | .map(|s| s.to_string()) 687 | .collect::>(), 688 | ), 689 | ( 690 | "/foo/bar/*.rs".to_string(), 691 | vec!["katherine.johnson"] 692 | .iter() 693 | .map(|s| s.to_string()) 694 | .collect::>(), 695 | ), 696 | ]); 697 | 698 | let expected = indoc!( 699 | "* @ada.lovelace 700 | /*.rs @ada.lovelace @margaret.hamilton 701 | /foo/bar/ @ada.lovelace @grace.hopper 702 | /foo/bar/*.rs @katherine.johnson" 703 | ) 704 | .to_string(); 705 | 706 | let codeowners_text = to_codeowners_string(codeowners); 707 | 708 | assert_eq!(codeowners_text, expected); 709 | 710 | Ok(()) 711 | } 712 | 713 | #[test] 714 | fn to_codeowners_string_multilevel_sorting() -> anyhow::Result<()> { 715 | let codeowners = HashMap::from([ 716 | ( 717 | "/foo/bar/*.rs".to_string(), 718 | vec!["katherine.johnson"] 719 | .iter() 720 | .map(|s| s.to_string()) 721 | .collect::>(), 722 | ), 723 | ( 724 | "/".to_string(), 725 | vec!["ada.lovelace"] 726 | .iter() 727 | .map(|s| s.to_string()) 728 | .collect::>(), 729 | ), 730 | ( 731 | "/foo/bar/".to_string(), 732 | vec!["ada.lovelace", "grace.hopper"] 733 | .iter() 734 | .map(|s| s.to_string()) 735 | .collect::>(), 736 | ), 737 | ( 738 | "/*.rs".to_string(), 739 | vec!["ada.lovelace", "margaret.hamilton"] 740 | .iter() 741 | .map(|s| s.to_string()) 742 | .collect::>(), 743 | ), 744 | ]); 745 | 746 | let expected = indoc!( 747 | "* @ada.lovelace 748 | /*.rs @ada.lovelace @margaret.hamilton 749 | /foo/bar/ @ada.lovelace @grace.hopper 750 | /foo/bar/*.rs @katherine.johnson" 751 | ) 752 | .to_string(); 753 | 754 | let codeowners_text = to_codeowners_string(codeowners); 755 | 756 | assert_eq!(codeowners_text, expected); 757 | 758 | Ok(()) 759 | } 760 | 761 | #[test] 762 | fn to_codeowners_string_subdir_without_owners() -> anyhow::Result<()> { 763 | let codeowners = HashMap::from([ 764 | ( 765 | "/".to_string(), 766 | vec!["ada.lovelace"] 767 | .iter() 768 | .map(|s| s.to_string()) 769 | .collect::>(), 770 | ), 771 | ( 772 | "/*.rs".to_string(), 773 | vec!["ada.lovelace", "margaret.hamilton"] 774 | .iter() 775 | .map(|s| s.to_string()) 776 | .collect::>(), 777 | ), 778 | ("/foo/bar/".to_string(), HashSet::default()), 779 | ( 780 | "/foo/bar/*.rs".to_string(), 781 | vec!["katherine.johnson"] 782 | .iter() 783 | .map(|s| s.to_string()) 784 | .collect::>(), 785 | ), 786 | ]); 787 | 788 | let expected = indoc!( 789 | "* @ada.lovelace 790 | /*.rs @ada.lovelace @margaret.hamilton 791 | /foo/bar/ 792 | /foo/bar/*.rs @katherine.johnson" 793 | ) 794 | .to_string(); 795 | 796 | let codeowners_text = to_codeowners_string(codeowners); 797 | 798 | assert_eq!(codeowners_text, expected); 799 | 800 | Ok(()) 801 | } 802 | } 803 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::allow_filter::{AllowFilter, AllowList, FilterGitMetadata}; 2 | use clap::Parser; 3 | use clap_verbosity_flag::Verbosity; 4 | use std::path::PathBuf; 5 | 6 | mod codeowners; 7 | mod owners_file; 8 | mod owners_set; 9 | mod owners_tree; 10 | mod pipeline; 11 | 12 | mod allow_filter; 13 | #[cfg(test)] 14 | mod test_utils; 15 | 16 | const DEFAULT_IMPLICIT_INHERIT: bool = true; 17 | 18 | #[derive(Parser, Debug)] 19 | #[clap(author, version, about)] 20 | /// A tool for auto generating GitHub compatible CODEOWNERS files from OWNERS files distributed 21 | /// through the file tree. 22 | struct Args { 23 | /// Root file in the repository from which to generate a CODEOWNERS file. 24 | #[clap(short, long)] 25 | repo_root: Option, 26 | 27 | /// Output file to write the resulting CODEOWNERS contents into. 28 | #[clap(short, long)] 29 | output_file: Option, 30 | 31 | /// Whether to inherit owners when inheritance is not specified. Default: true. 32 | #[clap(short, long, parse(try_from_str))] 33 | // NB: Option allows for --implicit-inherit [true|false] 34 | implicit_inherit: Option, 35 | 36 | /// Don't filter out files which are not managed by git. 37 | #[clap(long)] 38 | allow_non_git_files: bool, 39 | 40 | /// Add custom message to the auto-generated header/footer. 41 | /// 42 | /// This can be useful if you want to provide context for your specific project, 43 | /// such as manual steps to regenerate the file. 44 | #[clap(short, long)] 45 | message: Option, 46 | 47 | #[clap(flatten)] 48 | verbose: Verbosity, 49 | } 50 | 51 | fn run_pipeline(args: Args, allow_filter: &F) -> anyhow::Result<()> { 52 | pipeline::generate_codeowners_from_files( 53 | args.repo_root, 54 | args.output_file, 55 | args.implicit_inherit.unwrap_or(DEFAULT_IMPLICIT_INHERIT), 56 | allow_filter, 57 | args.message, 58 | ) 59 | } 60 | 61 | fn main() -> anyhow::Result<()> { 62 | let args = Args::parse(); 63 | env_logger::Builder::new() 64 | .filter_level(args.verbose.log_level_filter()) 65 | .init(); 66 | 67 | if args.allow_non_git_files { 68 | let allow_filter = FilterGitMetadata {}; 69 | run_pipeline(args, &allow_filter) 70 | } else { 71 | let allow_filter = AllowList::allow_git_files()?; 72 | run_pipeline(args, &allow_filter) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/owners_file.rs: -------------------------------------------------------------------------------- 1 | use crate::owners_set::OwnersSet; 2 | use anyhow::anyhow; 3 | use lazy_static::lazy_static; 4 | use regex::Regex; 5 | use std::collections::HashMap; 6 | use std::fs; 7 | use std::path::Path; 8 | 9 | #[derive(PartialEq, Debug, Default)] 10 | pub struct OwnersFileConfig { 11 | pub all_files: OwnersSet, 12 | pub pattern_overrides: HashMap, 13 | } 14 | 15 | impl OwnersFileConfig { 16 | pub fn from_text, S1: AsRef>( 17 | text: S0, 18 | source: S1, 19 | ) -> anyhow::Result { 20 | let text = text.as_ref(); 21 | let mut config = OwnersFileConfig::default(); 22 | let mut current_set = &mut config.all_files; 23 | 24 | for (line_number, line) in text.lines().enumerate() { 25 | let line = clean_line(line); 26 | if line.is_empty() { 27 | continue; 28 | } 29 | let is_set_line = current_set.maybe_process_set(line).map_err(|error| { 30 | anyhow!( 31 | "{} Encountered at {}:{}", 32 | error.to_string(), 33 | source.as_ref(), 34 | line_number 35 | ) 36 | })?; 37 | if is_set_line { 38 | continue; 39 | } 40 | if let Some(new_file_pattern) = maybe_get_file_pattern(line) { 41 | config 42 | .pattern_overrides 43 | .insert(new_file_pattern.clone(), OwnersSet::default()); 44 | current_set = config 45 | .pattern_overrides 46 | .get_mut(new_file_pattern.as_str()) 47 | .unwrap(); 48 | continue; 49 | } 50 | if line.contains(char::is_whitespace) { 51 | return Err(anyhow!( 52 | "Invalid user/group '{}' cannot contain whitespace. Found at {}:{}", 53 | line, 54 | source.as_ref(), 55 | line_number 56 | )); 57 | } 58 | current_set.owners.insert(line.to_string()); 59 | } 60 | 61 | Ok(config) 62 | } 63 | 64 | pub fn from_file>(path: P) -> anyhow::Result { 65 | let text = fs::read_to_string(&path)?; 66 | Self::from_text( 67 | text, 68 | path.as_ref() 69 | .to_str() 70 | .expect("Error converting file path to string"), 71 | ) 72 | } 73 | } 74 | 75 | /// Remove extraneous info in the line, such as comments and surrounding whitespace. 76 | fn clean_line(line: &str) -> &str { 77 | line.find('#').map(|i| &line[..i]).unwrap_or(line).trim() 78 | } 79 | 80 | fn maybe_get_file_pattern(line: &str) -> Option { 81 | lazy_static! { 82 | static ref RE: Regex = Regex::new(r"^\s*\[\s*(?\S+)\s*]\s*$").unwrap(); 83 | } 84 | if let Some(captures) = RE.captures(line) { 85 | let pattern = &captures["pattern"]; 86 | Some(pattern.to_string()) 87 | } else { 88 | None 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use crate::owners_file::{maybe_get_file_pattern, OwnersFileConfig}; 95 | use crate::owners_set::OwnersSet; 96 | use indoc::indoc; 97 | use std::collections::{HashMap, HashSet}; 98 | 99 | #[test] 100 | fn parse_blanket_owners_only() -> anyhow::Result<()> { 101 | let input = indoc! {"\ 102 | ada.lovelace 103 | grace.hopper 104 | margaret.hamilton 105 | " 106 | }; 107 | let expected = OwnersFileConfig { 108 | all_files: OwnersSet { 109 | inherit: None, 110 | owners: vec!["ada.lovelace", "grace.hopper", "margaret.hamilton"] 111 | .into_iter() 112 | .map(|s| s.to_string()) 113 | .collect::>(), 114 | }, 115 | pattern_overrides: HashMap::default(), 116 | }; 117 | 118 | let parsed = OwnersFileConfig::from_text(input, "test data")?; 119 | assert_eq!(parsed, expected); 120 | Ok(()) 121 | } 122 | 123 | #[test] 124 | fn parse_blanket_owners_with_inherit() -> anyhow::Result<()> { 125 | let input = indoc! {"\ 126 | set inherit = false 127 | ada.lovelace 128 | grace.hopper 129 | margaret.hamilton 130 | " 131 | }; 132 | let expected = OwnersFileConfig { 133 | all_files: OwnersSet { 134 | inherit: Some(false), 135 | owners: vec!["ada.lovelace", "grace.hopper", "margaret.hamilton"] 136 | .into_iter() 137 | .map(|s| s.to_string()) 138 | .collect::>(), 139 | }, 140 | pattern_overrides: HashMap::default(), 141 | }; 142 | 143 | let parsed = OwnersFileConfig::from_text(input, "test data")?; 144 | assert_eq!(parsed, expected); 145 | Ok(()) 146 | } 147 | 148 | #[test] 149 | fn parse_blanket_with_pattern_overrides() -> anyhow::Result<()> { 150 | let input = indoc! {"\ 151 | ada.lovelace 152 | grace.hopper 153 | margaret.hamilton 154 | 155 | [*.rs] 156 | katherine.johnson 157 | " 158 | }; 159 | let expected = OwnersFileConfig { 160 | all_files: OwnersSet { 161 | inherit: None, 162 | owners: vec!["ada.lovelace", "grace.hopper", "margaret.hamilton"] 163 | .into_iter() 164 | .map(|s| s.to_string()) 165 | .collect::>(), 166 | }, 167 | pattern_overrides: HashMap::from([( 168 | "*.rs".to_string(), 169 | OwnersSet { 170 | inherit: None, 171 | owners: vec!["katherine.johnson"] 172 | .into_iter() 173 | .map(|s| s.to_string()) 174 | .collect::>(), 175 | }, 176 | )]), 177 | }; 178 | 179 | let parsed = OwnersFileConfig::from_text(input, "test data")?; 180 | assert_eq!(parsed, expected); 181 | Ok(()) 182 | } 183 | 184 | #[test] 185 | fn test_maybe_get_file_pattern() { 186 | assert_eq!(maybe_get_file_pattern("[*.rs]"), Some("*.rs".to_string())); 187 | assert_eq!(maybe_get_file_pattern("[foo.*]"), Some("foo.*".to_string())); 188 | assert_eq!( 189 | maybe_get_file_pattern(" [ bar.* ] "), 190 | Some("bar.*".to_string()) 191 | ); 192 | assert_eq!(maybe_get_file_pattern("ada.lovelace"), None); 193 | assert_eq!(maybe_get_file_pattern(""), None); 194 | assert_eq!(maybe_get_file_pattern("set inherit = false"), None); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/owners_set.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use lazy_static::lazy_static; 3 | use regex::Regex; 4 | use std::collections::HashSet; 5 | 6 | #[derive(PartialEq, Debug, Default)] 7 | pub struct OwnersSet { 8 | pub inherit: Option, 9 | pub owners: HashSet, 10 | } 11 | 12 | impl OwnersSet { 13 | /// Evaluates the line for set variable syntax. If found, the variable specified will be updated 14 | /// to match the value specified. 15 | /// 16 | /// returns whether the line was a set line 17 | pub fn maybe_process_set(&mut self, line: &str) -> anyhow::Result { 18 | if !line.starts_with("set ") { 19 | return Ok(false); 20 | } 21 | lazy_static! { 22 | static ref RE: Regex = 23 | Regex::new(r"^\s*set\s(?\w+)\s*=\s*(?\w+)\s*$").unwrap(); 24 | } 25 | if let Some(captures) = RE.captures(line) { 26 | let variable = &captures["variable"]; 27 | let value = &captures["value"]; 28 | match variable { 29 | "inherit" => match value { 30 | "true" => { 31 | self.inherit = Some(true); 32 | } 33 | "false" => { 34 | self.inherit = Some(false); 35 | } 36 | _ => { 37 | return Err(anyhow!( 38 | "Invalid value for inherit '{}': Must be 'true' or 'false'.", 39 | value 40 | )) 41 | } 42 | }, 43 | _ => { 44 | return Err(anyhow!("Invalid set variable '{}'", variable,)); 45 | } 46 | } 47 | } else { 48 | return Err(anyhow!( 49 | "Invalid set format '{}']. Expected 'set = '.", 50 | line, 51 | )); 52 | } 53 | Ok(true) 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use crate::owners_set::OwnersSet; 60 | 61 | #[test] 62 | fn process_set_non_set() -> anyhow::Result<()> { 63 | let mut owners_set = OwnersSet::default(); 64 | assert!(!owners_set.maybe_process_set("ada.lovelace")?); 65 | Ok(()) 66 | } 67 | 68 | #[test] 69 | fn process_set_nominal_true() -> anyhow::Result<()> { 70 | let mut owners_set = OwnersSet::default(); 71 | assert!(owners_set.maybe_process_set("set inherit = true")?); 72 | assert_eq!(owners_set.inherit, Some(true)); 73 | Ok(()) 74 | } 75 | 76 | #[test] 77 | fn process_set_nominal_false() -> anyhow::Result<()> { 78 | let mut owners_set = OwnersSet::default(); 79 | assert!(owners_set.maybe_process_set("set inherit = false")?); 80 | assert_eq!(owners_set.inherit, Some(false)); 81 | Ok(()) 82 | } 83 | 84 | #[test] 85 | fn process_set_invalid() -> anyhow::Result<()> { 86 | let mut owners_set = OwnersSet::default(); 87 | assert!(is_error_with_text( 88 | owners_set.maybe_process_set("set inherit = not_a_bool"), 89 | "Invalid value" 90 | )); 91 | assert!(is_error_with_text( 92 | owners_set.maybe_process_set("set foo = bar"), 93 | "Invalid set variable" 94 | )); 95 | Ok(()) 96 | } 97 | 98 | fn is_error_with_text(result: anyhow::Result, contents: &str) -> bool { 99 | if result.is_ok() { 100 | return false; 101 | } 102 | let error = result.err().unwrap(); 103 | let message = error.to_string(); 104 | if message.contains(contents) { 105 | return true; 106 | } 107 | eprintln!("Error message missing expected '{contents}', in \n '{message}'"); 108 | false 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/owners_tree.rs: -------------------------------------------------------------------------------- 1 | use crate::allow_filter::AllowFilter; 2 | use crate::owners_file::OwnersFileConfig; 3 | use log::{debug, trace}; 4 | use std::fs; 5 | use std::path::{Path, PathBuf}; 6 | 7 | #[derive(PartialEq, Debug, Default)] 8 | pub struct TreeNode { 9 | pub path: PathBuf, 10 | pub owners_config: OwnersFileConfig, 11 | pub children: Vec, 12 | } 13 | 14 | pub type OwnersTree = TreeNode; 15 | 16 | impl TreeNode { 17 | pub fn new>(path: P) -> TreeNode { 18 | TreeNode { 19 | path: path.as_ref().to_path_buf(), 20 | ..TreeNode::default() 21 | } 22 | } 23 | 24 | pub fn maybe_load_owners_file(&mut self, allow_filter: &F) -> anyhow::Result 25 | where 26 | F: AllowFilter, 27 | { 28 | let owners_file = self.path.join("OWNERS"); 29 | if !owners_file.exists() || !owners_file.is_file() { 30 | return Ok(false); 31 | } 32 | if !allow_filter.allowed(&owners_file) { 33 | trace!( 34 | "Skipping {:?} in {:?} due to filter", 35 | owners_file, 36 | self.path 37 | ); 38 | return Ok(false); 39 | } 40 | 41 | debug!("Parsing {:?}", &owners_file); 42 | let owners_config = OwnersFileConfig::from_file(owners_file)?; 43 | self.owners_config = owners_config; 44 | 45 | Ok(true) 46 | } 47 | 48 | pub fn load_from_files(root: P, allow_filter: &F) -> anyhow::Result 49 | where 50 | P: AsRef, 51 | F: AllowFilter, 52 | { 53 | let mut root_node = TreeNode::new(&root); 54 | root_node.maybe_load_owners_file(allow_filter)?; 55 | for entry in fs::read_dir(root)? { 56 | let entry = entry?; 57 | let path = entry.path(); 58 | if path.is_dir() && 59 | // Don't process file tree branches with no allowed files 60 | allow_filter.allowed(&path) 61 | { 62 | root_node.load_children_from_files(&path, allow_filter)?; 63 | } 64 | } 65 | Ok(root_node) 66 | } 67 | 68 | fn load_children_from_files( 69 | &mut self, 70 | directory: &Path, 71 | allow_filter: &F, 72 | ) -> anyhow::Result<()> 73 | where 74 | F: AllowFilter, 75 | { 76 | if directory.file_name().unwrap() == ".git" { 77 | // Don't process git metadata 78 | return Ok(()); 79 | } 80 | let mut current_loc_node = TreeNode::new(directory); 81 | let has_current_owners_file = current_loc_node.maybe_load_owners_file(allow_filter)?; 82 | for entry in fs::read_dir(directory)? { 83 | let entry = entry?; 84 | let path = entry.path(); 85 | if path.is_dir() { 86 | if has_current_owners_file { 87 | current_loc_node.load_children_from_files(&path, allow_filter)?; 88 | } else { 89 | self.load_children_from_files(&path, allow_filter)?; 90 | } 91 | } 92 | } 93 | if has_current_owners_file { 94 | self.children.push(current_loc_node); 95 | } 96 | Ok(()) 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use crate::allow_filter::FilterGitMetadata; 103 | use crate::owners_file::OwnersFileConfig; 104 | use crate::owners_set::OwnersSet; 105 | use crate::owners_tree::{OwnersTree, TreeNode}; 106 | use crate::test_utils::create_test_file; 107 | use indoc::indoc; 108 | use std::collections::HashSet; 109 | use tempfile::tempdir; 110 | 111 | const ALLOW_ANY: FilterGitMetadata = FilterGitMetadata {}; 112 | 113 | #[test] 114 | fn single_file_at_root() -> anyhow::Result<()> { 115 | let temp_dir = tempdir()?; 116 | create_test_file( 117 | &temp_dir, 118 | "OWNERS", 119 | indoc! {"\ 120 | ada.lovelace 121 | grace.hopper 122 | margaret.hamilton 123 | " 124 | }, 125 | )?; 126 | let tree = OwnersTree::load_from_files(temp_dir.path(), &ALLOW_ANY)?; 127 | let expected = TreeNode { 128 | path: temp_dir.path().to_path_buf(), 129 | owners_config: OwnersFileConfig { 130 | all_files: OwnersSet { 131 | owners: vec![ 132 | "ada.lovelace".to_string(), 133 | "grace.hopper".to_string(), 134 | "margaret.hamilton".to_string(), 135 | ] 136 | .into_iter() 137 | .collect::>(), 138 | ..OwnersSet::default() 139 | }, 140 | ..OwnersFileConfig::default() 141 | }, 142 | ..TreeNode::default() 143 | }; 144 | 145 | assert_eq!(tree, expected); 146 | Ok(()) 147 | } 148 | 149 | #[test] 150 | fn single_file_not_at_root() -> anyhow::Result<()> { 151 | let temp_dir = tempdir()?; 152 | create_test_file( 153 | &temp_dir, 154 | "subdir/OWNERS", 155 | indoc! {"\ 156 | ada.lovelace 157 | grace.hopper 158 | margaret.hamilton 159 | " 160 | }, 161 | )?; 162 | let tree = OwnersTree::load_from_files(temp_dir.path(), &ALLOW_ANY)?; 163 | let expected = TreeNode { 164 | path: temp_dir.path().to_path_buf(), 165 | children: vec![TreeNode { 166 | path: temp_dir.path().join("subdir").to_path_buf(), 167 | owners_config: OwnersFileConfig { 168 | all_files: OwnersSet { 169 | owners: vec![ 170 | "ada.lovelace".to_string(), 171 | "grace.hopper".to_string(), 172 | "margaret.hamilton".to_string(), 173 | ] 174 | .into_iter() 175 | .collect::>(), 176 | ..OwnersSet::default() 177 | }, 178 | ..OwnersFileConfig::default() 179 | }, 180 | ..TreeNode::default() 181 | }], 182 | ..TreeNode::default() 183 | }; 184 | 185 | assert_eq!(tree, expected); 186 | Ok(()) 187 | } 188 | 189 | #[test] 190 | fn multiple_files() -> anyhow::Result<()> { 191 | let temp_dir = tempdir()?; 192 | create_test_file( 193 | &temp_dir, 194 | "OWNERS", 195 | indoc! {"\ 196 | ada.lovelace 197 | grace.hopper 198 | " 199 | }, 200 | )?; 201 | create_test_file( 202 | &temp_dir, 203 | "subdir/foo/OWNERS", 204 | indoc! {"\ 205 | margaret.hamilton 206 | katherine.johnson 207 | " 208 | }, 209 | )?; 210 | let tree = OwnersTree::load_from_files(temp_dir.path(), &ALLOW_ANY)?; 211 | let expected = TreeNode { 212 | path: temp_dir.path().to_path_buf(), 213 | owners_config: OwnersFileConfig { 214 | all_files: OwnersSet { 215 | owners: vec!["ada.lovelace".to_string(), "grace.hopper".to_string()] 216 | .into_iter() 217 | .collect::>(), 218 | ..OwnersSet::default() 219 | }, 220 | ..OwnersFileConfig::default() 221 | }, 222 | children: vec![TreeNode { 223 | path: temp_dir.path().join("subdir/foo").to_path_buf(), 224 | owners_config: OwnersFileConfig { 225 | all_files: OwnersSet { 226 | owners: vec![ 227 | "margaret.hamilton".to_string(), 228 | "katherine.johnson".to_string(), 229 | ] 230 | .into_iter() 231 | .collect::>(), 232 | ..OwnersSet::default() 233 | }, 234 | ..OwnersFileConfig::default() 235 | }, 236 | ..TreeNode::default() 237 | }], 238 | }; 239 | 240 | assert_eq!(tree, expected); 241 | Ok(()) 242 | } 243 | 244 | #[test] 245 | fn ignore_hidden_files() -> anyhow::Result<()> { 246 | let temp_dir = tempdir()?; 247 | create_test_file( 248 | &temp_dir, 249 | "OWNERS", 250 | indoc! {"\ 251 | ada.lovelace 252 | grace.hopper 253 | " 254 | }, 255 | )?; 256 | create_test_file( 257 | &temp_dir, 258 | "subdir/.git/OWNERS", 259 | indoc! {"\ 260 | margaret.hamilton 261 | katherine.johnson 262 | " 263 | }, 264 | )?; 265 | let tree = OwnersTree::load_from_files(temp_dir.path(), &ALLOW_ANY)?; 266 | let expected = TreeNode { 267 | path: temp_dir.path().to_path_buf(), 268 | owners_config: OwnersFileConfig { 269 | all_files: OwnersSet { 270 | owners: vec!["ada.lovelace".to_string(), "grace.hopper".to_string()] 271 | .into_iter() 272 | .collect::>(), 273 | ..OwnersSet::default() 274 | }, 275 | ..OwnersFileConfig::default() 276 | }, 277 | ..TreeNode::default() 278 | }; 279 | 280 | assert_eq!(tree, expected); 281 | Ok(()) 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/pipeline.rs: -------------------------------------------------------------------------------- 1 | use crate::allow_filter::AllowFilter; 2 | use crate::codeowners::{generate_codeowners, to_codeowners_string}; 3 | use crate::owners_tree::OwnersTree; 4 | use indoc::indoc; 5 | use std::fs; 6 | use std::fs::create_dir_all; 7 | use std::path::PathBuf; 8 | use textwrap::wrap; 9 | 10 | fn get_auto_generated_notice>(message: Option) -> String { 11 | let mut out = indoc! {"\ 12 | ################################################################################ 13 | # AUTO GENERATED FILE 14 | # Do Not Manually Update 15 | " 16 | } 17 | .to_string(); 18 | 19 | if let Some(message) = message { 20 | wrap(message.as_ref(), 78).iter().for_each(|line| { 21 | out.push_str(format!("# {: ^78}", line).trim()); 22 | out.push('\n'); 23 | }); 24 | } 25 | 26 | out.push_str(indoc! {"\ 27 | # For details, see: 28 | # https://github.com/andrewring/github-distributed-owners#readme 29 | ################################################################################" 30 | }); 31 | 32 | out 33 | } 34 | 35 | pub fn generate_codeowners_from_files( 36 | repo_root: Option, 37 | output_file: Option, 38 | implicit_inherit: bool, 39 | allow_filter: &F, 40 | message: Option, 41 | ) -> anyhow::Result<()> 42 | where 43 | F: AllowFilter, 44 | S: AsRef, 45 | { 46 | let root = repo_root.unwrap_or(std::env::current_dir()?); 47 | let tree = OwnersTree::load_from_files(root, allow_filter)?; 48 | 49 | let codeowners = generate_codeowners(&tree, implicit_inherit)?; 50 | let mut codeowners_text = to_codeowners_string(codeowners); 51 | let auto_generated_notice = get_auto_generated_notice(message); 52 | 53 | codeowners_text = 54 | format!("{auto_generated_notice}\n\n{codeowners_text}\n\n{auto_generated_notice}"); 55 | 56 | match output_file { 57 | None => println!("{}", codeowners_text), 58 | Some(output_file) => { 59 | if let Some(parent_dir) = output_file.parent() { 60 | create_dir_all(parent_dir)?; 61 | } 62 | 63 | // Files should end with a newline 64 | codeowners_text.push('\n'); 65 | 66 | fs::write(output_file, codeowners_text)?; 67 | } 68 | } 69 | 70 | Ok(()) 71 | } 72 | 73 | #[cfg(test)] 74 | mod test { 75 | use crate::allow_filter::FilterGitMetadata; 76 | use crate::pipeline::{generate_codeowners_from_files, get_auto_generated_notice}; 77 | use crate::test_utils::create_test_file; 78 | use indoc::indoc; 79 | use std::fs; 80 | use tempfile::tempdir; 81 | 82 | const ALLOW_ANY: FilterGitMetadata = FilterGitMetadata {}; 83 | 84 | #[test] 85 | fn test_generate_codeowners_from_files_simple() -> anyhow::Result<()> { 86 | let temp_dir = tempdir()?; 87 | let root_dir = temp_dir.path(); 88 | create_test_file( 89 | &temp_dir, 90 | "OWNERS", 91 | indoc! { 92 | "ada.lovelace 93 | grace.hopper 94 | [*.rs] 95 | foo.bar 96 | " 97 | }, 98 | )?; 99 | 100 | let expected = indoc! {"\ 101 | ################################################################################ 102 | # AUTO GENERATED FILE 103 | # Do Not Manually Update 104 | # For details, see: 105 | # https://github.com/andrewring/github-distributed-owners#readme 106 | ################################################################################ 107 | 108 | * @ada.lovelace @grace.hopper 109 | /*.rs @ada.lovelace @foo.bar @grace.hopper 110 | 111 | ################################################################################ 112 | # AUTO GENERATED FILE 113 | # Do Not Manually Update 114 | # For details, see: 115 | # https://github.com/andrewring/github-distributed-owners#readme 116 | ################################################################################ 117 | " 118 | }; 119 | 120 | let output_file = root_dir.join("CODEOWNERS"); 121 | let repo_root = Some(root_dir.to_path_buf()); 122 | let implicit_inherit = true; 123 | let message = Option::::None; 124 | 125 | generate_codeowners_from_files( 126 | repo_root, 127 | Some(output_file.clone()), 128 | implicit_inherit, 129 | &ALLOW_ANY, 130 | message, 131 | )?; 132 | 133 | let generated_codeowners = fs::read_to_string(output_file)?; 134 | 135 | assert_eq!(generated_codeowners, expected); 136 | 137 | Ok(()) 138 | } 139 | 140 | #[test] 141 | fn test_generate_codeowners_from_files_multiple_files() -> anyhow::Result<()> { 142 | let temp_dir = tempdir()?; 143 | let root_dir = temp_dir.path(); 144 | create_test_file( 145 | &temp_dir, 146 | "OWNERS", 147 | indoc! { 148 | "ada.lovelace 149 | grace.hopper 150 | " 151 | }, 152 | )?; 153 | create_test_file( 154 | &temp_dir, 155 | "subdir/foo/OWNERS", 156 | indoc! {"\ 157 | katherine.johnson 158 | margaret.hamilton 159 | " 160 | }, 161 | )?; 162 | 163 | let expected = indoc! {"\ 164 | ################################################################################ 165 | # AUTO GENERATED FILE 166 | # Do Not Manually Update 167 | # For details, see: 168 | # https://github.com/andrewring/github-distributed-owners#readme 169 | ################################################################################ 170 | 171 | * @ada.lovelace @grace.hopper 172 | /subdir/foo/ @ada.lovelace @grace.hopper @katherine.johnson @margaret.hamilton 173 | 174 | ################################################################################ 175 | # AUTO GENERATED FILE 176 | # Do Not Manually Update 177 | # For details, see: 178 | # https://github.com/andrewring/github-distributed-owners#readme 179 | ################################################################################ 180 | " 181 | }; 182 | 183 | let output_file = root_dir.join("CODEOWNERS"); 184 | let repo_root = Some(root_dir.to_path_buf()); 185 | let implicit_inherit = true; 186 | let message = Option::::None; 187 | 188 | generate_codeowners_from_files( 189 | repo_root, 190 | Some(output_file.clone()), 191 | implicit_inherit, 192 | &ALLOW_ANY, 193 | message, 194 | )?; 195 | 196 | let generated_codeowners = fs::read_to_string(output_file)?; 197 | 198 | assert_eq!(generated_codeowners, expected); 199 | 200 | Ok(()) 201 | } 202 | 203 | #[test] 204 | fn test_generate_codeowners_from_files_multiple_files_with_overrides() -> anyhow::Result<()> { 205 | let temp_dir = tempdir()?; 206 | let root_dir = temp_dir.path(); 207 | create_test_file( 208 | &temp_dir, 209 | "OWNERS", 210 | indoc! { 211 | "ada.lovelace 212 | grace.hopper 213 | " 214 | }, 215 | )?; 216 | create_test_file( 217 | &temp_dir, 218 | "subdir/foo/OWNERS", 219 | indoc! {"\ 220 | katherine.johnson 221 | margaret.hamilton 222 | 223 | [*.rs] 224 | set inherit = false 225 | grace.hopper 226 | " 227 | }, 228 | )?; 229 | 230 | let expected = indoc! {"\ 231 | ################################################################################ 232 | # AUTO GENERATED FILE 233 | # Do Not Manually Update 234 | # For details, see: 235 | # https://github.com/andrewring/github-distributed-owners#readme 236 | ################################################################################ 237 | 238 | * @ada.lovelace @grace.hopper 239 | /subdir/foo/ @ada.lovelace @grace.hopper @katherine.johnson @margaret.hamilton 240 | /subdir/foo/*.rs @grace.hopper 241 | 242 | ################################################################################ 243 | # AUTO GENERATED FILE 244 | # Do Not Manually Update 245 | # For details, see: 246 | # https://github.com/andrewring/github-distributed-owners#readme 247 | ################################################################################ 248 | " 249 | }; 250 | 251 | let output_file = root_dir.join("CODEOWNERS"); 252 | let repo_root = Some(root_dir.to_path_buf()); 253 | let implicit_inherit = true; 254 | let message = Option::::None; 255 | 256 | generate_codeowners_from_files( 257 | repo_root, 258 | Some(output_file.clone()), 259 | implicit_inherit, 260 | &ALLOW_ANY, 261 | message, 262 | )?; 263 | 264 | let generated_codeowners = fs::read_to_string(output_file)?; 265 | 266 | assert_eq!(generated_codeowners, expected); 267 | 268 | Ok(()) 269 | } 270 | 271 | #[test] 272 | fn test_generate_codeowners_from_files_empty_root_blanket_owners() -> anyhow::Result<()> { 273 | let temp_dir = tempdir()?; 274 | let root_dir = temp_dir.path(); 275 | create_test_file( 276 | &temp_dir, 277 | "OWNERS", 278 | indoc! { 279 | "[*.rs] 280 | ada.lovelace 281 | grace.hopper 282 | " 283 | }, 284 | )?; 285 | create_test_file( 286 | &temp_dir, 287 | "subdir/foo/OWNERS", 288 | indoc! {"\ 289 | katherine.johnson 290 | margaret.hamilton 291 | 292 | [*.rs] 293 | set inherit = false 294 | grace.hopper 295 | " 296 | }, 297 | )?; 298 | 299 | let expected = indoc! {"\ 300 | ################################################################################ 301 | # AUTO GENERATED FILE 302 | # Do Not Manually Update 303 | # For details, see: 304 | # https://github.com/andrewring/github-distributed-owners#readme 305 | ################################################################################ 306 | 307 | /*.rs @ada.lovelace @grace.hopper 308 | /subdir/foo/ @katherine.johnson @margaret.hamilton 309 | /subdir/foo/*.rs @grace.hopper 310 | 311 | ################################################################################ 312 | # AUTO GENERATED FILE 313 | # Do Not Manually Update 314 | # For details, see: 315 | # https://github.com/andrewring/github-distributed-owners#readme 316 | ################################################################################ 317 | " 318 | }; 319 | 320 | let output_file = root_dir.join("CODEOWNERS"); 321 | let repo_root = Some(root_dir.to_path_buf()); 322 | let implicit_inherit = true; 323 | let message = Option::::None; 324 | 325 | generate_codeowners_from_files( 326 | repo_root, 327 | Some(output_file.clone()), 328 | implicit_inherit, 329 | &ALLOW_ANY, 330 | message, 331 | )?; 332 | 333 | let generated_codeowners = fs::read_to_string(output_file)?; 334 | 335 | assert_eq!(generated_codeowners, expected); 336 | 337 | Ok(()) 338 | } 339 | 340 | #[test] 341 | fn test_get_auto_generated_notice_default() { 342 | let expected = indoc! {"\ 343 | ################################################################################ 344 | # AUTO GENERATED FILE 345 | # Do Not Manually Update 346 | # For details, see: 347 | # https://github.com/andrewring/github-distributed-owners#readme 348 | ################################################################################" 349 | }; 350 | assert_eq!(get_auto_generated_notice::(None), expected); 351 | } 352 | 353 | #[test] 354 | fn test_get_auto_generated_notice_short() { 355 | let expected = indoc! {"\ 356 | ################################################################################ 357 | # AUTO GENERATED FILE 358 | # Do Not Manually Update 359 | # Some short text on one line 360 | # For details, see: 361 | # https://github.com/andrewring/github-distributed-owners#readme 362 | ################################################################################" 363 | }; 364 | let message = "Some short text on one line"; 365 | assert_eq!(get_auto_generated_notice(Some(message)), expected); 366 | } 367 | 368 | #[test] 369 | fn test_get_auto_generated_notice_multiline() { 370 | let expected = indoc! {"\ 371 | ################################################################################ 372 | # AUTO GENERATED FILE 373 | # Do Not Manually Update 374 | # A much longer custom message which doesn't fit on a single line. It will need 375 | # to be wrapped into multiple lines, neatly. 376 | # For details, see: 377 | # https://github.com/andrewring/github-distributed-owners#readme 378 | ################################################################################" 379 | }; 380 | let message = 381 | "A much longer custom message which doesn't fit on a single line. It will need to be wrapped into multiple \ 382 | lines, neatly."; 383 | assert_eq!(get_auto_generated_notice(Some(message)), expected); 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /src/test_utils.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use tempfile::TempDir; 3 | 4 | pub fn create_test_file(temp_dir: &TempDir, path: &str, contents: &str) -> anyhow::Result<()> { 5 | let full_path = temp_dir.path().join(path); 6 | fs::create_dir_all(full_path.parent().unwrap())?; 7 | fs::write(full_path, contents)?; 8 | Ok(()) 9 | } 10 | --------------------------------------------------------------------------------