├── .github └── workflows │ ├── cd.yml │ ├── ci.yml │ ├── pull_request_template.md │ ├── stale.yml │ └── triage.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── codeownership ├── code_ownership.gemspec ├── lib ├── code_ownership.rb └── code_ownership │ ├── cli.rb │ ├── configuration.rb │ ├── mapper.rb │ ├── private.rb │ ├── private │ ├── codeowners_file.rb │ ├── extension_loader.rb │ ├── glob_cache.rb │ ├── owner_assigner.rb │ ├── ownership_mappers │ │ ├── directory_ownership.rb │ │ ├── file_annotations.rb │ │ ├── js_package_ownership.rb │ │ ├── package_ownership.rb │ │ ├── team_globs.rb │ │ └── team_yml_ownership.rb │ ├── parse_js_packages.rb │ ├── permit_pack_owner_top_level_key.rb │ ├── team_plugins │ │ ├── github.rb │ │ └── ownership.rb │ └── validations │ │ ├── files_have_owners.rb │ │ ├── files_have_unique_owners.rb │ │ └── github_codeowners_up_to_date.rb │ └── validator.rb ├── sorbet ├── config └── rbi │ ├── gems │ ├── code_teams@1.0.0.rbi │ ├── packs@0.0.2.rbi │ └── packwerk@3.0.1.rbi │ ├── manual.rbi │ └── todo.rbi └── spec ├── lib ├── code_ownership │ ├── cli_spec.rb │ └── private │ │ ├── codeowners_file_spec.rb │ │ ├── extension_loader_spec.rb │ │ ├── owner_assigner_spec.rb │ │ ├── ownership_mappers │ │ ├── directory_ownership_spec.rb │ │ ├── file_annotations_spec.rb │ │ ├── js_package_ownership_spec.rb │ │ ├── package_ownership_spec.rb │ │ ├── team_globs_spec.rb │ │ └── team_yml_ownership_spec.rb │ │ └── validations │ │ ├── files_have_owners_spec.rb │ │ ├── files_have_unique_owners_spec.rb │ │ └── github_codeowners_up_to_date_spec.rb └── code_ownership_spec.rb ├── spec_helper.rb └── support └── application_fixtures.rb /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | types: [completed] 7 | branches: [main] 8 | 9 | jobs: 10 | call-workflow-from-shared-config: 11 | uses: rubyatscale/shared-config/.github/workflows/cd.yml@main 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | rspec: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby: 15 | - 3.1 16 | - 3.2 17 | - 3.3 18 | - 3.4 19 | env: 20 | BUNDLE_GEMFILE: Gemfile 21 | name: "RSpec tests: Ruby ${{ matrix.ruby }}" 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Ruby ${{ matrix.ruby }} 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | bundler-cache: true 28 | ruby-version: ${{ matrix.ruby }} 29 | - name: Run tests 30 | run: bundle exec rspec 31 | static_type_check: 32 | name: "Type Check" 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Ruby 37 | uses: ruby/setup-ruby@v1 38 | with: 39 | bundler-cache: true 40 | ruby-version: 3.3 41 | - name: Run static type checks 42 | run: bundle exec srb tc 43 | notify_on_failure: 44 | runs-on: ubuntu-latest 45 | needs: [rspec, static_type_check] 46 | if: ${{ failure() && github.ref == 'refs/heads/main' }} 47 | env: 48 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 49 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 50 | steps: 51 | - uses: slackapi/slack-github-action@v1.25.0 52 | with: 53 | payload: | 54 | { 55 | "text": "${{ github.repository }}/${{ github.ref }}: FAILED\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_template.md: -------------------------------------------------------------------------------- 1 | * [ ] I bumped the gem version (or don't need to) 💎 -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | jobs: 7 | call-workflow-from-shared-config: 8 | uses: rubyatscale/shared-config/.github/workflows/stale.yml@main 9 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: Label issues as "triage" 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | jobs: 8 | call-workflow-from-shared-config: 9 | uses: rubyatscale/shared-config/.github/workflows/triage.yml@main 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | Gemfile.lock 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # The behavior of RuboCop can be controlled via the .rubocop.yml 2 | # configuration file. It makes it possible to enable/disable 3 | # certain cops (checks) and to alter their behavior if they accept 4 | # any parameters. The file can be placed either in your home 5 | # directory or in some project directory. 6 | # 7 | # RuboCop will start looking for the configuration file in the directory 8 | # where the inspected file is and continue its way up to the root directory. 9 | # 10 | # See https://docs.rubocop.org/rubocop/configuration 11 | AllCops: 12 | NewCops: enable 13 | Exclude: 14 | - vendor/bundle/**/** 15 | TargetRubyVersion: 2.6 16 | 17 | Metrics/ParameterLists: 18 | Enabled: false 19 | 20 | # This cop is annoying with typed configuration 21 | Style/TrivialAccessors: 22 | Enabled: false 23 | 24 | # This rubocop is annoying when we use interfaces a lot 25 | Lint/UnusedMethodArgument: 26 | Enabled: false 27 | 28 | Gemspec/RequireMFA: 29 | Enabled: false 30 | 31 | Lint/DuplicateBranch: 32 | Enabled: false 33 | 34 | # If is sometimes easier to think about than unless sometimes 35 | Style/NegatedIf: 36 | Enabled: false 37 | 38 | # Disabling for now until it's clearer why we want this 39 | Style/FrozenStringLiteralComment: 40 | Enabled: false 41 | 42 | # It's nice to be able to read the condition first before reading the code within the condition 43 | Style/GuardClause: 44 | Enabled: false 45 | 46 | # 47 | # Leaving length metrics to human judgment for now 48 | # 49 | Metrics/ModuleLength: 50 | Enabled: false 51 | 52 | Layout/LineLength: 53 | Enabled: false 54 | 55 | Metrics/BlockLength: 56 | Enabled: false 57 | 58 | Metrics/MethodLength: 59 | Enabled: false 60 | 61 | Metrics/AbcSize: 62 | Enabled: false 63 | 64 | Metrics/ClassLength: 65 | Enabled: false 66 | 67 | # This doesn't feel useful 68 | Metrics/CyclomaticComplexity: 69 | Enabled: false 70 | 71 | # This doesn't feel useful 72 | Metrics/PerceivedComplexity: 73 | Enabled: false 74 | 75 | # It's nice to be able to read the condition first before reading the code within the condition 76 | Style/IfUnlessModifier: 77 | Enabled: false 78 | 79 | # This leads to code that is not very readable at times (very long lines) 80 | Style/ConditionalAssignment: 81 | Enabled: false 82 | 83 | # For now, we prefer to lean on clean method signatures as documentation. We may change this later. 84 | Style/Documentation: 85 | Enabled: false 86 | 87 | # Sometimes we leave comments in empty else statements intentionally 88 | Style/EmptyElse: 89 | Enabled: false 90 | 91 | # Sometimes we want to more explicitly list out a condition 92 | Style/RedundantCondition: 93 | Enabled: false 94 | 95 | # This leads to code that is not very readable at times (very long lines) 96 | Layout/MultilineMethodCallIndentation: 97 | Enabled: false 98 | 99 | # Blocks across lines are okay sometimes 100 | Style/BlockDelimiters: 101 | Enabled: false 102 | 103 | # Sometimes we like methods like `get_packages` 104 | Naming/AccessorMethodName: 105 | Enabled: false 106 | 107 | # This leads to code that is not very readable at times (very long lines) 108 | Layout/FirstArgumentIndentation: 109 | Enabled: false 110 | 111 | # This leads to code that is not very readable at times (very long lines) 112 | Layout/ArgumentAlignment: 113 | Enabled: false 114 | 115 | Style/AccessorGrouping: 116 | Enabled: false 117 | 118 | Style/HashSyntax: 119 | Enabled: false 120 | 121 | Gemspec/DevelopmentDependencies: 122 | Enabled: true 123 | EnforcedStyle: gemspec -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See https://github.com/rubyatscale/code_ownership/releases 2 | -------------------------------------------------------------------------------- /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, caste, color, religion, or sexual 10 | identity 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 overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | 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 address, 35 | 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 rubyatscale@gusto.com. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series of 85 | actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or permanent 92 | ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within the 112 | community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.1, available at 118 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 125 | [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 129 | [Mozilla CoC]: https://github.com/mozilla/diversity 130 | [FAQ]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gusto 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeOwnership 2 | 3 | This gem helps engineering teams declare ownership of code. This gem works best in large, usually monolithic code bases where many teams work together. 4 | 5 | Check out [`lib/code_ownership.rb`](https://github.com/rubyatscale/code_ownership/blob/main/lib/code_ownership.rb) to see the public API. 6 | 7 | Check out [`code_ownership_spec.rb`](https://github.com/rubyatscale/code_ownership/blob/main/spec/lib/code_ownership_spec.rb) to see examples of how code ownership is used. 8 | 9 | There is also a [companion VSCode Extension]([url](https://github.com/rubyatscale/code-ownership-vscode)) for this gem. Just search `Gusto.code-ownership-vscode` in the VSCode Extension Marketplace. 10 | 11 | ## Getting started 12 | 13 | To get started there's a few things you should do. 14 | 15 | 1) Create a `config/code_ownership.yml` file and declare where your files live. Here's a sample to start with: 16 | ```yml 17 | owned_globs: 18 | - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}' 19 | js_package_paths: [] 20 | unowned_globs: 21 | - db/**/* 22 | - app/services/some_file1.rb 23 | - app/services/some_file2.rb 24 | - frontend/javascripts/**/__generated__/**/* 25 | ``` 26 | 2) Declare some teams. Here's an example, that would live at `config/teams/operations.yml`: 27 | ```yml 28 | name: Operations 29 | github: 30 | team: '@my-org/operations-team' 31 | ``` 32 | 3) Declare ownership. You can do this at a directory level or at a file level. All of the files within the `owned_globs` you declared in step 1 will need to have an owner assigned (or be opted out via `unowned_globs`). See the next section for more detail. 33 | 4) Run validations when you commit, and/or in CI. If you run validations in CI, ensure that if your `.github/CODEOWNERS` file gets changed, that gets pushed to the PR. 34 | 35 | ## Usage: Declaring Ownership 36 | 37 | There are five ways to declare code ownership using this gem: 38 | 39 | ### Directory-Based Ownership 40 | Directory based ownership allows for all files in that directory and all its sub-directories to be owned by one team. To define this, add a `.codeowner` file inside that directory with the name of the team as the contents of that file. 41 | ``` 42 | Team 43 | ``` 44 | 45 | ### File-Annotation Based Ownership 46 | File annotations are a last resort if there is no clear home for your code. File annotations go at the top of your file, and look like this: 47 | ```ruby 48 | # @team MyTeam 49 | ``` 50 | 51 | ### Package-Based Ownership 52 | Package based ownership integrates [`packwerk`](https://github.com/Shopify/packwerk) and has ownership defined per package. To define that all files within a package are owned by one team, configure your `package.yml` like this: 53 | ```yml 54 | enforce_dependency: true 55 | enforce_privacy: true 56 | metadata: 57 | owner: Team 58 | ``` 59 | 60 | You can also define `owner` as a top-level key, e.g. 61 | ```yml 62 | enforce_dependency: true 63 | enforce_privacy: true 64 | owner: Team 65 | ``` 66 | 67 | To do this, add `code_ownership` to the `require` key of your `packwerk.yml`. See https://github.com/Shopify/packwerk/blob/main/USAGE.md#loading-extensions for more information. 68 | 69 | ### Glob-Based Ownership 70 | In your team's configured YML (see [`code_teams`](https://github.com/rubyatscale/code_teams)), you can set `owned_globs` to be a glob of files your team owns. For example, in `my_team.yml`: 71 | ```yml 72 | name: My Team 73 | owned_globs: 74 | - app/services/stuff_belonging_to_my_team/**/** 75 | - app/controllers/other_stuff_belonging_to_my_team/**/** 76 | unowned_globs: 77 | - app/controllers/other_stuff_belonging_to_my_team/that_one_weird_dir_we_dont_own/* 78 | ``` 79 | 80 | ### Javascript Package Ownership 81 | Javascript package based ownership allows you to specify an ownership key in a `package.json`. To use this, configure your `package.json` like this: 82 | 83 | ```json 84 | { 85 | // other keys 86 | "metadata": { 87 | "owner": "My Team" 88 | } 89 | // other keys 90 | } 91 | ``` 92 | 93 | You can also tell `code_ownership` where to find JS packages in the configuration, like this: 94 | ```yml 95 | js_package_paths: 96 | - frontend/javascripts/packages/* 97 | - frontend/other_location_for_packages/* 98 | ``` 99 | 100 | This defaults `**/`, which makes it look for `package.json` files across your application. 101 | 102 | > [!NOTE] 103 | > Javscript package ownership does not respect `unowned_globs`. If you wish to disable usage of this feature you can set `js_package_paths` to an empty list. 104 | ```yml 105 | js_package_paths: [] 106 | ``` 107 | 108 | ### Custom Ownership 109 | To enable custom ownership, you can inject your own custom classes into `code_ownership`. 110 | To do this, first create a class that adheres to the `CodeOwnership::Mapper` and/or `CodeOwnership::Validator` interface. 111 | Then, in `config/code_ownership.yml`, you can require that file: 112 | ```yml 113 | require: 114 | - ./lib/my_extension.rb 115 | ``` 116 | 117 | Now, `bin/codeownership validate` will automatically include your new mapper and/or validator. See [`spec/lib/code_ownership/private/extension_loader_spec.rb](spec/lib/code_ownership/private/extension_loader_spec.rb) for an example of what this looks like. 118 | 119 | ## Usage: Reading CodeOwnership 120 | ### `for_file` 121 | `CodeOwnership.for_file`, given a relative path to a file returns a `CodeTeams::Team` if there is a team that owns the file, `nil` otherwise. 122 | 123 | ```ruby 124 | CodeOwnership.for_file('path/to/file/relative/to/application/root.rb') 125 | ``` 126 | 127 | Contributor note: If you are making updates to this method or the methods getting used here, please benchmark the performance of the new implementation against the current for both `for_files` and `for_file` (with 1, 100, 1000 files). 128 | 129 | See `code_ownership_spec.rb` for examples. 130 | 131 | ### `for_backtrace` 132 | `CodeOwnership.for_backtrace` can be given a backtrace and will either return `nil`, or a `CodeTeams::Team`. 133 | 134 | ```ruby 135 | CodeOwnership.for_backtrace(exception.backtrace) 136 | ``` 137 | 138 | This will go through the backtrace, and return the first found owner of the files associated with frames within the backtrace. 139 | 140 | See `code_ownership_spec.rb` for an example. 141 | 142 | ### `for_class` 143 | 144 | `CodeOwnership.for_class` can be given a class and will either return `nil`, or a `CodeTeams::Team`. 145 | 146 | ```ruby 147 | CodeOwnership.for_class(MyClass) 148 | ``` 149 | 150 | Under the hood, this finds the file where the class is defined and returns the owner of that file. 151 | 152 | See `code_ownership_spec.rb` for an example. 153 | 154 | ### `for_team` 155 | `CodeOwnership.for_team` can be used to generate an ownership report for a team. 156 | ```ruby 157 | CodeOwnership.for_team('My Team') 158 | ``` 159 | 160 | You can shovel this into a markdown file for easy viewing using the CLI: 161 | ``` 162 | bin/codeownership for_team 'My Team' > tmp/ownership_report.md 163 | ``` 164 | 165 | ## Usage: Generating a `CODEOWNERS` file 166 | 167 | A `CODEOWNERS` file defines who owns specific files or paths in a repository. When you run `bin/codeownership validate`, a `.github/CODEOWNERS` file will automatically be generated and updated. 168 | 169 | If `codeowners_path` is set in `code_ownership.yml` codeowners will use that path to generate the `CODEOWNERS` file. For example, `codeowners_path: docs` will generate `docs/CODEOWNERS`. 170 | 171 | ## Proper Configuration & Validation 172 | 173 | CodeOwnership comes with a validation function to ensure the following things are true: 174 | 175 | 1) Only one mechanism is defining file ownership. That is -- you can't have a file annotation on a file owned via package-based or glob-based ownership. This helps make ownership behavior more clear by avoiding concerns about precedence. 176 | 2) All teams referenced as an owner for any file or package is a valid team (i.e. it's in the list of `CodeTeams.all`). 177 | 3) All files have ownership. You can specify in `unowned_globs` to represent a TODO list of files to add ownership to. 178 | 3) The `.github/CODEOWNERS` file is up to date. This is automatically corrected and staged unless specified otherwise with `bin/codeownership validate --skip-autocorrect --skip-stage`. You can turn this validation off by setting `skip_codeowners_validation: true` in `config/code_ownership.yml`. 179 | 180 | CodeOwnership also allows you to specify which globs and file extensions should be considered ownable. 181 | 182 | Here is an example `config/code_ownership.yml`. 183 | ```yml 184 | owned_globs: 185 | - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}' 186 | unowned_globs: 187 | - db/**/* 188 | - app/services/some_file1.rb 189 | - app/services/some_file2.rb 190 | - frontend/javascripts/**/__generated__/**/* 191 | ``` 192 | You can call the validation function with the Ruby API 193 | ```ruby 194 | CodeOwnership.validate! 195 | ``` 196 | or the CLI 197 | ``` 198 | bin/codeownership validate 199 | ``` 200 | 201 | ## Development 202 | 203 | Please add to `CHANGELOG.md` and this `README.md` when you make make changes. 204 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # typed: ignore 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/codeownership: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # typed: strict 3 | 4 | require 'code_ownership' 5 | CodeOwnership::Cli.run!(ARGV) 6 | -------------------------------------------------------------------------------- /code_ownership.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |spec| 2 | spec.name = 'code_ownership' 3 | spec.version = '1.39.0' 4 | spec.authors = ['Gusto Engineers'] 5 | spec.email = ['dev@gusto.com'] 6 | spec.summary = 'A gem to help engineering teams declare ownership of code' 7 | spec.description = 'A gem to help engineering teams declare ownership of code' 8 | spec.homepage = 'https://github.com/rubyatscale/code_ownership' 9 | spec.license = 'MIT' 10 | spec.required_ruby_version = '>= 2.6' 11 | 12 | if spec.respond_to?(:metadata) 13 | spec.metadata['homepage_uri'] = spec.homepage 14 | spec.metadata['source_code_uri'] = 'https://github.com/rubyatscale/code_ownership' 15 | spec.metadata['changelog_uri'] = 'https://github.com/rubyatscale/code_ownership/releases' 16 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 17 | else 18 | raise 'RubyGems 2.0 or newer is required to protect against ' \ 19 | 'public gem pushes.' 20 | end 21 | # https://guides.rubygems.org/make-your-own-gem/#adding-an-executable 22 | # and 23 | # https://bundler.io/blog/2015/03/20/moving-bins-to-exe.html 24 | spec.executables = ['codeownership'] 25 | 26 | # Specify which files should be added to the gem when it is released. 27 | spec.files = Dir['README.md', 'lib/**/*', 'bin/**/*'] 28 | spec.require_paths = ['lib'] 29 | 30 | spec.add_dependency 'code_teams', '~> 1.0' 31 | spec.add_dependency 'packs-specification' 32 | spec.add_dependency 'sorbet-runtime', '>= 0.5.11249' 33 | 34 | spec.add_development_dependency 'debug' 35 | spec.add_development_dependency 'packwerk' 36 | spec.add_development_dependency 'railties' 37 | spec.add_development_dependency 'rake' 38 | spec.add_development_dependency 'rspec', '~> 3.0' 39 | spec.add_development_dependency 'rubocop' 40 | spec.add_development_dependency 'sorbet' 41 | spec.add_development_dependency 'tapioca' 42 | end 43 | -------------------------------------------------------------------------------- /lib/code_ownership.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: strict 4 | 5 | require 'set' 6 | require 'code_teams' 7 | require 'sorbet-runtime' 8 | require 'json' 9 | require 'packs-specification' 10 | require 'code_ownership/mapper' 11 | require 'code_ownership/validator' 12 | require 'code_ownership/private' 13 | require 'code_ownership/cli' 14 | require 'code_ownership/configuration' 15 | 16 | if defined?(Packwerk) 17 | require 'code_ownership/private/permit_pack_owner_top_level_key' 18 | end 19 | 20 | module CodeOwnership 21 | module_function 22 | 23 | extend T::Sig 24 | extend T::Helpers 25 | 26 | requires_ancestor { Kernel } 27 | GlobsToOwningTeamMap = T.type_alias { T::Hash[String, CodeTeams::Team] } 28 | 29 | sig { params(file: String).returns(T.nilable(CodeTeams::Team)) } 30 | def for_file(file) 31 | @for_file ||= T.let(@for_file, T.nilable(T::Hash[String, T.nilable(CodeTeams::Team)])) 32 | @for_file ||= {} 33 | 34 | return nil if file.start_with?('./') 35 | return @for_file[file] if @for_file.key?(file) 36 | 37 | Private.load_configuration! 38 | 39 | owner = T.let(nil, T.nilable(CodeTeams::Team)) 40 | 41 | Mapper.all.each do |mapper| 42 | owner = mapper.map_file_to_owner(file) 43 | break if owner # TODO: what if there are multiple owners? Should we respond with an error instead of the first match? 44 | end 45 | 46 | @for_file[file] = owner 47 | end 48 | 49 | sig { params(team: T.any(CodeTeams::Team, String)).returns(String) } 50 | def for_team(team) 51 | team = T.must(CodeTeams.find(team)) if team.is_a?(String) 52 | ownership_information = T.let([], T::Array[String]) 53 | 54 | ownership_information << "# Code Ownership Report for `#{team.name}` Team" 55 | 56 | Private.glob_cache.raw_cache_contents.each do |mapper_description, glob_to_owning_team_map| 57 | ownership_information << "## #{mapper_description}" 58 | ownership_for_mapper = [] 59 | glob_to_owning_team_map.each do |glob, owning_team| 60 | next if owning_team != team 61 | 62 | ownership_for_mapper << "- #{glob}" 63 | end 64 | 65 | if ownership_for_mapper.empty? 66 | ownership_information << 'This team owns nothing in this category.' 67 | else 68 | ownership_information += ownership_for_mapper.sort 69 | end 70 | 71 | ownership_information << '' 72 | end 73 | 74 | ownership_information.join("\n") 75 | end 76 | 77 | class InvalidCodeOwnershipConfigurationError < StandardError 78 | end 79 | 80 | sig { params(filename: String).void } 81 | def self.remove_file_annotation!(filename) 82 | Private::OwnershipMappers::FileAnnotations.new.remove_file_annotation!(filename) 83 | end 84 | 85 | sig do 86 | params( 87 | autocorrect: T::Boolean, 88 | stage_changes: T::Boolean, 89 | files: T.nilable(T::Array[String]) 90 | ).void 91 | end 92 | def validate!( 93 | autocorrect: true, 94 | stage_changes: true, 95 | files: nil 96 | ) 97 | Private.load_configuration! 98 | 99 | tracked_file_subset = if files 100 | files.select { |f| Private.file_tracked?(f) } 101 | else 102 | Private.tracked_files 103 | end 104 | 105 | Private.validate!(files: tracked_file_subset, autocorrect: autocorrect, stage_changes: stage_changes) 106 | end 107 | 108 | # Given a backtrace from either `Exception#backtrace` or `caller`, find the 109 | # first line that corresponds to a file with assigned ownership 110 | sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) } 111 | def for_backtrace(backtrace, excluded_teams: []) 112 | first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)&.first 113 | end 114 | 115 | # Given a backtrace from either `Exception#backtrace` or `caller`, find the 116 | # first owned file in it, useful for figuring out which file is being blamed. 117 | sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) } 118 | def first_owned_file_for_backtrace(backtrace, excluded_teams: []) 119 | backtrace_with_ownership(backtrace).each do |(team, file)| 120 | if team && !excluded_teams.include?(team) 121 | return [team, file] 122 | end 123 | end 124 | 125 | nil 126 | end 127 | 128 | sig { params(backtrace: T.nilable(T::Array[String])).returns(T::Enumerable[[T.nilable(::CodeTeams::Team), String]]) } 129 | def backtrace_with_ownership(backtrace) 130 | return [] unless backtrace 131 | 132 | # The pattern for a backtrace hasn't changed in forever and is considered 133 | # stable: https://github.com/ruby/ruby/blob/trunk/vm_backtrace.c#L303-L317 134 | # 135 | # This pattern matches a line like the following: 136 | # 137 | # ./app/controllers/some_controller.rb:43:in `block (3 levels) in create' 138 | # 139 | backtrace_line = if RUBY_VERSION >= '3.4.0' 140 | %r{\A(#{Pathname.pwd}/|\./)? 141 | (?.+) # Matches 'app/controllers/some_controller.rb' 142 | : 143 | (?\d+) # Matches '43' 144 | :in\s 145 | '(?.*)' # Matches "`block (3 levels) in create'" 146 | \z}x 147 | else 148 | %r{\A(#{Pathname.pwd}/|\./)? 149 | (?.+) # Matches 'app/controllers/some_controller.rb' 150 | : 151 | (?\d+) # Matches '43' 152 | :in\s 153 | `(?.*)' # Matches "`block (3 levels) in create'" 154 | \z}x 155 | end 156 | 157 | backtrace.lazy.filter_map do |line| 158 | match = line.match(backtrace_line) 159 | next unless match 160 | 161 | file = T.must(match[:file]) 162 | 163 | [ 164 | CodeOwnership.for_file(file), 165 | file 166 | ] 167 | end 168 | end 169 | private_class_method(:backtrace_with_ownership) 170 | 171 | sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) } 172 | def for_class(klass) 173 | @memoized_values ||= T.let(@memoized_values, T.nilable(T::Hash[String, T.nilable(::CodeTeams::Team)])) 174 | @memoized_values ||= {} 175 | # We use key because the memoized value could be `nil` 176 | if @memoized_values.key?(klass.to_s) 177 | @memoized_values[klass.to_s] 178 | else 179 | path = Private.path_from_klass(klass) 180 | return nil if path.nil? 181 | 182 | value_to_memoize = for_file(path) 183 | @memoized_values[klass.to_s] = value_to_memoize 184 | value_to_memoize 185 | end 186 | end 187 | 188 | sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) } 189 | def for_package(package) 190 | Private::OwnershipMappers::PackageOwnership.new.owner_for_package(package) 191 | end 192 | 193 | # Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change. 194 | # Namely, the set of files, packages, and directories which are tracked for ownership should not change. 195 | # The primary reason this is helpful is for clients of CodeOwnership who want to test their code, and each test context 196 | # has different ownership and tracked files. 197 | sig { void } 198 | def self.bust_caches! 199 | @for_file = nil 200 | @memoized_values = nil 201 | Private.bust_caches! 202 | Mapper.all.each(&:bust_caches!) 203 | end 204 | 205 | sig { returns(Configuration) } 206 | def self.configuration 207 | Private.configuration 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/code_ownership/cli.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | require 'optparse' 4 | require 'pathname' 5 | require 'fileutils' 6 | 7 | module CodeOwnership 8 | class Cli 9 | def self.run!(argv) 10 | command = argv.shift 11 | if command == 'validate' 12 | validate!(argv) 13 | elsif command == 'for_file' 14 | for_file(argv) 15 | elsif command == 'for_team' 16 | for_team(argv) 17 | elsif [nil, 'help'].include?(command) 18 | puts <<~USAGE 19 | Usage: bin/codeownership 20 | 21 | Subcommands: 22 | validate - run all validations 23 | for_file - find code ownership for a single file 24 | for_team - find code ownership information for a team 25 | help - display help information about code_ownership 26 | USAGE 27 | else 28 | puts "'#{command}' is not a code_ownership command. See `bin/codeownership help`." 29 | end 30 | end 31 | 32 | def self.validate!(argv) 33 | options = {} 34 | 35 | parser = OptionParser.new do |opts| 36 | opts.banner = 'Usage: bin/codeownership validate [options]' 37 | 38 | opts.on('--skip-autocorrect', 'Skip automatically correcting any errors, such as the .github/CODEOWNERS file') do 39 | options[:skip_autocorrect] = true 40 | end 41 | 42 | opts.on('-d', '--diff', 'Only run validations with staged files') do 43 | options[:diff] = true 44 | end 45 | 46 | opts.on('-s', '--skip-stage', 'Skips staging the CODEOWNERS file') do 47 | options[:skip_stage] = true 48 | end 49 | 50 | opts.on('--help', 'Shows this prompt') do 51 | puts opts 52 | exit 53 | end 54 | end 55 | args = parser.order!(argv) 56 | parser.parse!(args) 57 | 58 | files = if options[:diff] 59 | ENV.fetch('CODEOWNERS_GIT_STAGED_FILES') { `git diff --staged --name-only` }.split("\n").select do |file| 60 | File.exist?(file) 61 | end 62 | else 63 | nil 64 | end 65 | 66 | CodeOwnership.validate!( 67 | files: files, 68 | autocorrect: !options[:skip_autocorrect], 69 | stage_changes: !options[:skip_stage] 70 | ) 71 | end 72 | 73 | # For now, this just returns team ownership 74 | # Later, this could also return code ownership errors about that file. 75 | def self.for_file(argv) 76 | options = {} 77 | 78 | # Long-term, we probably want to use something like `thor` so we don't have to implement logic 79 | # like this. In the short-term, this is a simple way for us to use the built-in OptionParser 80 | # while having an ergonomic CLI. 81 | files = argv.reject { |arg| arg.start_with?('--') } 82 | 83 | parser = OptionParser.new do |opts| 84 | opts.banner = 'Usage: bin/codeownership for_file [options]' 85 | 86 | opts.on('--json', 'Output as JSON') do 87 | options[:json] = true 88 | end 89 | 90 | opts.on('--help', 'Shows this prompt') do 91 | puts opts 92 | exit 93 | end 94 | end 95 | args = parser.order!(argv) 96 | parser.parse!(args) 97 | 98 | if files.count != 1 99 | raise 'Please pass in one file. Use `bin/codeownership for_file --help` for more info' 100 | end 101 | 102 | team = CodeOwnership.for_file(files.first) 103 | 104 | team_name = team&.name || 'Unowned' 105 | team_yml = team&.config_yml || 'Unowned' 106 | 107 | if options[:json] 108 | json = { 109 | team_name: team_name, 110 | team_yml: team_yml 111 | } 112 | 113 | puts json.to_json 114 | else 115 | puts <<~MSG 116 | Team: #{team_name} 117 | Team YML: #{team_yml} 118 | MSG 119 | end 120 | end 121 | 122 | def self.for_team(argv) 123 | parser = OptionParser.new do |opts| 124 | opts.banner = 'Usage: bin/codeownership for_team \'Team Name\'' 125 | 126 | opts.on('--help', 'Shows this prompt') do 127 | puts opts 128 | exit 129 | end 130 | end 131 | teams = argv.reject { |arg| arg.start_with?('--') } 132 | args = parser.order!(argv) 133 | parser.parse!(args) 134 | 135 | if teams.count != 1 136 | raise 'Please pass in one team. Use `bin/codeownership for_team --help` for more info' 137 | end 138 | 139 | puts CodeOwnership.for_team(teams.first) 140 | end 141 | 142 | private_class_method :validate! 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/code_ownership/configuration.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module CodeOwnership 4 | class Configuration < T::Struct 5 | extend T::Sig 6 | DEFAULT_JS_PACKAGE_PATHS = T.let(['**/'], T::Array[String]) 7 | 8 | const :owned_globs, T::Array[String] 9 | const :unowned_globs, T::Array[String] 10 | const :js_package_paths, T::Array[String] 11 | const :unbuilt_gems_path, T.nilable(String) 12 | const :skip_codeowners_validation, T::Boolean 13 | const :raw_hash, T::Hash[T.untyped, T.untyped] 14 | const :require_github_teams, T::Boolean 15 | const :codeowners_path, String 16 | 17 | sig { returns(Configuration) } 18 | def self.fetch 19 | config_hash = YAML.load_file('config/code_ownership.yml') 20 | 21 | if config_hash.key?('require') 22 | config_hash['require'].each do |require_directive| 23 | Private::ExtensionLoader.load(require_directive) 24 | end 25 | end 26 | 27 | new( 28 | owned_globs: config_hash.fetch('owned_globs', []), 29 | unowned_globs: config_hash.fetch('unowned_globs', []), 30 | js_package_paths: js_package_paths(config_hash), 31 | skip_codeowners_validation: config_hash.fetch('skip_codeowners_validation', false), 32 | raw_hash: config_hash, 33 | require_github_teams: config_hash.fetch('require_github_teams', false), 34 | codeowners_path: config_hash.fetch('codeowners_path', '.github'), 35 | ) 36 | end 37 | 38 | sig { params(config_hash: T::Hash[T.untyped, T.untyped]).returns(T::Array[String]) } 39 | def self.js_package_paths(config_hash) 40 | specified_package_paths = config_hash['js_package_paths'] 41 | if specified_package_paths.nil? 42 | DEFAULT_JS_PACKAGE_PATHS.dup 43 | else 44 | Array(specified_package_paths) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/code_ownership/mapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: strict 4 | 5 | module CodeOwnership 6 | module Mapper 7 | extend T::Sig 8 | extend T::Helpers 9 | 10 | interface! 11 | 12 | class << self 13 | extend T::Sig 14 | 15 | sig { params(base: T::Class[Mapper]).void } 16 | def included(base) 17 | @mappers ||= T.let(@mappers, T.nilable(T::Array[T::Class[Mapper]])) 18 | @mappers ||= [] 19 | @mappers << base 20 | end 21 | 22 | sig { returns(T::Array[Mapper]) } 23 | def all 24 | (@mappers || []).map(&:new) 25 | end 26 | end 27 | 28 | # 29 | # This should be fast when run with ONE file 30 | # 31 | sig do 32 | abstract.params(file: String) 33 | .returns(T.nilable(::CodeTeams::Team)) 34 | end 35 | def map_file_to_owner(file); end 36 | 37 | # 38 | # This should be fast when run with MANY files 39 | # 40 | sig do 41 | abstract.params(files: T::Array[String]) 42 | .returns(T::Hash[String, ::CodeTeams::Team]) 43 | end 44 | def globs_to_owner(files); end 45 | 46 | # 47 | # This should be fast when run with MANY files 48 | # 49 | sig do 50 | abstract.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 51 | end 52 | def update_cache(cache, files); end 53 | 54 | sig { abstract.returns(String) } 55 | def description; end 56 | 57 | sig { abstract.void } 58 | def bust_caches!; end 59 | 60 | sig { returns(Private::GlobCache) } 61 | def self.to_glob_cache 62 | glob_to_owner_map_by_mapper_description = {} 63 | 64 | Mapper.all.each do |mapper| 65 | mapped_files = mapper.globs_to_owner(Private.tracked_files) 66 | glob_to_owner_map_by_mapper_description[mapper.description] ||= {} 67 | 68 | mapped_files.each do |glob, owner| 69 | next if owner.nil? 70 | 71 | glob_to_owner_map_by_mapper_description.fetch(mapper.description)[glob] = owner 72 | end 73 | end 74 | 75 | Private::GlobCache.new(glob_to_owner_map_by_mapper_description) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/code_ownership/private.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: strict 4 | 5 | require 'code_ownership/private/extension_loader' 6 | require 'code_ownership/private/team_plugins/ownership' 7 | require 'code_ownership/private/team_plugins/github' 8 | require 'code_ownership/private/codeowners_file' 9 | require 'code_ownership/private/parse_js_packages' 10 | require 'code_ownership/private/glob_cache' 11 | require 'code_ownership/private/owner_assigner' 12 | require 'code_ownership/private/validations/files_have_owners' 13 | require 'code_ownership/private/validations/github_codeowners_up_to_date' 14 | require 'code_ownership/private/validations/files_have_unique_owners' 15 | require 'code_ownership/private/ownership_mappers/file_annotations' 16 | require 'code_ownership/private/ownership_mappers/team_globs' 17 | require 'code_ownership/private/ownership_mappers/directory_ownership' 18 | require 'code_ownership/private/ownership_mappers/package_ownership' 19 | require 'code_ownership/private/ownership_mappers/js_package_ownership' 20 | require 'code_ownership/private/ownership_mappers/team_yml_ownership' 21 | 22 | module CodeOwnership 23 | module Private 24 | extend T::Sig 25 | 26 | sig { returns(Configuration) } 27 | def self.configuration 28 | @configuration ||= T.let(@configuration, T.nilable(Configuration)) 29 | @configuration ||= Configuration.fetch 30 | end 31 | 32 | # This is just an alias for `configuration` that makes it more explicit what we're doing instead of just calling `configuration`. 33 | # This is necessary because configuration may contain extensions of code ownership, so those extensions should be loaded prior to 34 | # calling APIs that provide ownership information. 35 | sig { returns(Configuration) } 36 | def self.load_configuration! 37 | configuration 38 | end 39 | 40 | sig { void } 41 | def self.bust_caches! 42 | @configuration = nil 43 | @tracked_files = nil 44 | @glob_cache = nil 45 | end 46 | 47 | sig { params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).void } 48 | def self.validate!(files:, autocorrect: true, stage_changes: true) 49 | CodeownersFile.update_cache!(files) if CodeownersFile.use_codeowners_cache? 50 | 51 | errors = Validator.all.flat_map do |validator| 52 | validator.validation_errors( 53 | files: files, 54 | autocorrect: autocorrect, 55 | stage_changes: stage_changes 56 | ) 57 | end 58 | 59 | if errors.any? 60 | errors << 'See https://github.com/rubyatscale/code_ownership#README.md for more details' 61 | raise InvalidCodeOwnershipConfigurationError.new(errors.join("\n")) # rubocop:disable Style/RaiseArgs 62 | end 63 | end 64 | 65 | # Returns a string version of the relative path to a Rails constant, 66 | # or nil if it can't find something 67 | sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(String)) } 68 | def self.path_from_klass(klass) 69 | if klass 70 | path = Object.const_source_location(klass.to_s)&.first 71 | (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil 72 | else 73 | nil 74 | end 75 | rescue NameError 76 | nil 77 | end 78 | 79 | # 80 | # The output of this function is string pathnames relative to the root. 81 | # 82 | sig { returns(T::Array[String]) } 83 | def self.tracked_files 84 | @tracked_files ||= T.let(@tracked_files, T.nilable(T::Array[String])) 85 | @tracked_files ||= Dir.glob(configuration.owned_globs) - Dir.glob(configuration.unowned_globs) 86 | end 87 | 88 | sig { params(file: String).returns(T::Boolean) } 89 | def self.file_tracked?(file) 90 | # Another way to accomplish this is 91 | # (Dir.glob(configuration.owned_globs) - Dir.glob(configuration.unowned_globs)).include?(file) 92 | # However, globbing out can take 5 or more seconds on a large repository, dramatically slowing down 93 | # invocations to `bin/codeownership validate --diff`. 94 | # Using `File.fnmatch?` is a lot faster! 95 | in_owned_globs = configuration.owned_globs.any? do |owned_glob| 96 | File.fnmatch?(owned_glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB) 97 | end 98 | 99 | in_unowned_globs = configuration.unowned_globs.any? do |unowned_glob| 100 | File.fnmatch?(unowned_glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB) 101 | end 102 | 103 | in_owned_globs && !in_unowned_globs && File.exist?(file) 104 | end 105 | 106 | sig { params(team_name: String, location_of_reference: String).returns(CodeTeams::Team) } 107 | def self.find_team!(team_name, location_of_reference) 108 | found_team = CodeTeams.find(team_name) 109 | if found_team.nil? 110 | raise StandardError, "Could not find team with name: `#{team_name}` in #{location_of_reference}. Make sure the team is one of `#{CodeTeams.all.map(&:name).sort}`" 111 | else 112 | found_team 113 | end 114 | end 115 | 116 | sig { returns(GlobCache) } 117 | def self.glob_cache 118 | @glob_cache ||= T.let(@glob_cache, T.nilable(GlobCache)) 119 | @glob_cache ||= if CodeownersFile.use_codeowners_cache? 120 | CodeownersFile.to_glob_cache 121 | else 122 | Mapper.to_glob_cache 123 | end 124 | end 125 | end 126 | 127 | private_constant :Private 128 | end 129 | -------------------------------------------------------------------------------- /lib/code_ownership/private/codeowners_file.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | # frozen_string_literal: true 3 | 4 | module CodeOwnership 5 | module Private 6 | # 7 | # This class is responsible for turning CodeOwnership directives (e.g. annotations, package owners) 8 | # into a GitHub CODEOWNERS file, as specified here: 9 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 10 | # 11 | class CodeownersFile 12 | extend T::Sig 13 | 14 | sig { returns(T::Array[String]) } 15 | def self.actual_contents_lines 16 | if path.exist? 17 | content = path.read 18 | lines = path.read.split("\n") 19 | if content.end_with?("\n") 20 | lines << '' 21 | end 22 | lines 23 | else 24 | [''] 25 | end 26 | end 27 | 28 | sig { returns(T::Array[T.nilable(String)]) } 29 | def self.expected_contents_lines 30 | cache = Private.glob_cache.raw_cache_contents 31 | 32 | header = <<~HEADER 33 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 34 | # This file was automatically generated by "bin/codeownership validate". 35 | # 36 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 37 | # teams. This is useful when developers create Pull Requests since the 38 | # code/file owner is notified. Reference GitHub docs for more details: 39 | # https://help.github.com/en/articles/about-code-owners 40 | HEADER 41 | ignored_teams = T.let(Set.new, T::Set[String]) 42 | 43 | github_team_map = CodeTeams.all.each_with_object({}) do |team, map| 44 | team_github = TeamPlugins::Github.for(team).github 45 | if team_github.do_not_add_to_codeowners_file 46 | ignored_teams << team.name 47 | end 48 | 49 | map[team.name] = team_github.team 50 | end 51 | 52 | codeowners_file_lines = T.let([], T::Array[String]) 53 | 54 | cache.each do |mapper_description, ownership_map_cache| 55 | ownership_entries = [] 56 | sorted_ownership_map_cache = ownership_map_cache.sort_by do |glob, _team| 57 | glob 58 | end 59 | sorted_ownership_map_cache.to_h.each do |path, code_team| 60 | team_mapping = github_team_map[code_team.name] 61 | next if team_mapping.nil? 62 | 63 | # Leaving a commented out entry has two major benefits: 64 | # 1) It allows the CODEOWNERS file to be used as a cache for validations 65 | # 2) It allows users to specifically see what their team will not be notified about. 66 | entry = if ignored_teams.include?(code_team.name) 67 | "# /#{path} #{team_mapping}" 68 | else 69 | "/#{path} #{team_mapping}" 70 | end 71 | ownership_entries << entry 72 | end 73 | 74 | next if ownership_entries.none? 75 | 76 | # When we have a special character at the beginning of a folder name, then this character 77 | # may be prioritized over *. However, we want the most specific folder to be listed last 78 | # in the CODEOWNERS file, so we should prioritize any character above an asterisk in the 79 | # same position. 80 | if mapper_description == OwnershipMappers::FileAnnotations::DESCRIPTION 81 | # individually owned files definitely won't have globs so we don't need to do special sorting 82 | sorted_ownership_entries = ownership_entries.sort 83 | else 84 | sorted_ownership_entries = ownership_entries.sort do |entry1, entry2| 85 | if entry2.start_with?(entry1.split('**').first) 86 | -1 87 | elsif entry1.start_with?(entry2.split('**').first) 88 | 1 89 | else 90 | entry1 <=> entry2 91 | end 92 | end 93 | end 94 | 95 | codeowners_file_lines += ['', "# #{mapper_description}", *sorted_ownership_entries] 96 | end 97 | 98 | [ 99 | *header.split("\n"), 100 | '', # For line between header and codeowners_file_lines 101 | *codeowners_file_lines, 102 | '' # For end-of-file newline 103 | ] 104 | end 105 | 106 | sig { void } 107 | def self.write! 108 | FileUtils.mkdir_p(path.dirname) if !path.dirname.exist? 109 | path.write(expected_contents_lines.join("\n")) 110 | end 111 | 112 | sig { returns(Pathname) } 113 | def self.path 114 | Pathname.pwd.join( 115 | CodeOwnership.configuration.codeowners_path, 116 | 'CODEOWNERS' 117 | ) 118 | end 119 | 120 | sig { params(files: T::Array[String]).void } 121 | def self.update_cache!(files) 122 | cache = Private.glob_cache 123 | # Each mapper returns a new copy of the cache subset related to that mapper, 124 | # which is then stored back into the cache. 125 | Mapper.all.each do |mapper| 126 | existing_cache = cache.raw_cache_contents.fetch(mapper.description, {}) 127 | updated_cache = mapper.update_cache(existing_cache, files) 128 | cache.raw_cache_contents[mapper.description] = updated_cache 129 | end 130 | end 131 | 132 | sig { returns(T::Boolean) } 133 | def self.use_codeowners_cache? 134 | CodeownersFile.path.exist? && !Private.configuration.skip_codeowners_validation 135 | end 136 | 137 | sig { returns(GlobCache) } 138 | def self.to_glob_cache 139 | github_team_to_code_team_map = T.let({}, T::Hash[String, CodeTeams::Team]) 140 | CodeTeams.all.each do |team| 141 | github_team = TeamPlugins::Github.for(team).github.team 142 | github_team_to_code_team_map[github_team] = team 143 | end 144 | raw_cache_contents = T.let({}, GlobCache::CacheShape) 145 | current_mapper = T.let(nil, T.nilable(String)) 146 | mapper_descriptions = Set.new(Mapper.all.map(&:description)) 147 | 148 | path.readlines.each do |line| 149 | line_with_no_comment = line.chomp.gsub('# ', '') 150 | if mapper_descriptions.include?(line_with_no_comment) 151 | current_mapper = line_with_no_comment 152 | else 153 | next if current_mapper.nil? 154 | next if line.chomp == '' 155 | 156 | # The codeowners file stores paths relative to the root of directory 157 | # Since a `/` means root of the file system from the perspective of ruby, 158 | # we remove that beginning slash so we can correctly glob the files out. 159 | normalized_line = line.gsub(/^# /, '').gsub(%r{^/}, '') 160 | split_line = normalized_line.split 161 | # Most lines will be in the format: /path/to/file my-github-team 162 | # This will skip over lines that are not of the correct form 163 | next if split_line.count > 2 164 | 165 | entry, github_team = split_line 166 | code_team = github_team_to_code_team_map[T.must(github_team)] 167 | # If a GitHub team is changed and a user runs `bin/codeownership validate`, we won't be able to identify 168 | # what team is associated with the removed github team. 169 | # Therefore, if we can't determine the team, we just skip it. 170 | # This affects how complete the cache is, but that will still be caught by `bin/codeownership validate`. 171 | next if code_team.nil? 172 | 173 | raw_cache_contents[current_mapper] ||= {} 174 | raw_cache_contents.fetch(current_mapper)[T.must(entry)] = github_team_to_code_team_map.fetch(T.must(github_team)) 175 | end 176 | end 177 | 178 | GlobCache.new(raw_cache_contents) 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/code_ownership/private/extension_loader.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | # frozen_string_literal: true 3 | 4 | module CodeOwnership 5 | module Private 6 | # This class handles loading extensions to code_ownership using the `require` directive 7 | # in the `code_ownership.yml` configuration. 8 | module ExtensionLoader 9 | class << self 10 | extend T::Sig 11 | sig { params(require_directive: String).void } 12 | def load(require_directive) 13 | # We want to transform the require directive to behave differently 14 | # if it's a specific local file being required versus a gem 15 | if require_directive.start_with?('.') 16 | require File.join(Pathname.pwd, require_directive) 17 | else 18 | require require_directive 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/code_ownership/private/glob_cache.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | # frozen_string_literal: true 3 | 4 | module CodeOwnership 5 | module Private 6 | class GlobCache 7 | extend T::Sig 8 | 9 | MapperDescription = T.type_alias { String } 10 | 11 | CacheShape = T.type_alias do 12 | T::Hash[ 13 | MapperDescription, 14 | GlobsToOwningTeamMap 15 | ] 16 | end 17 | 18 | FilesByMapper = T.type_alias do 19 | T::Hash[ 20 | String, 21 | T::Set[MapperDescription] 22 | ] 23 | end 24 | 25 | sig { params(raw_cache_contents: CacheShape).void } 26 | def initialize(raw_cache_contents) 27 | @raw_cache_contents = raw_cache_contents 28 | end 29 | 30 | sig { returns(CacheShape) } 31 | def raw_cache_contents 32 | @raw_cache_contents 33 | end 34 | 35 | sig { params(files: T::Array[String]).returns(FilesByMapper) } 36 | def mapper_descriptions_that_map_files(files) 37 | files_by_mappers = files.to_h { |f| [f, Set.new([])] } 38 | 39 | files_by_mappers_via_expanded_cache.each do |file, mappers| 40 | mappers.each do |mapper| 41 | T.must(files_by_mappers[file]) << mapper if files_by_mappers[file] 42 | end 43 | end 44 | 45 | files_by_mappers 46 | end 47 | 48 | private 49 | 50 | sig { returns(CacheShape) } 51 | def expanded_cache 52 | @expanded_cache = T.let(@expanded_cache, T.nilable(CacheShape)) 53 | 54 | @expanded_cache ||= begin 55 | expanded_cache = {} 56 | @raw_cache_contents.each do |mapper_description, globs_by_owner| 57 | expanded_cache[mapper_description] = OwnerAssigner.assign_owners(globs_by_owner) 58 | end 59 | expanded_cache 60 | end 61 | end 62 | 63 | sig { returns(FilesByMapper) } 64 | def files_by_mappers_via_expanded_cache 65 | @files_by_mappers_via_expanded_cache ||= T.let(@files_by_mappers_via_expanded_cache, T.nilable(FilesByMapper)) 66 | @files_by_mappers_via_expanded_cache ||= begin 67 | files_by_mappers = T.let({}, FilesByMapper) 68 | expanded_cache.each do |mapper_description, file_by_owner| 69 | file_by_owner.each_key do |file| 70 | files_by_mappers[file] ||= Set.new([]) 71 | files_by_mappers.fetch(file) << mapper_description 72 | end 73 | end 74 | 75 | files_by_mappers 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/code_ownership/private/owner_assigner.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | # frozen_string_literal: true 3 | 4 | module CodeOwnership 5 | module Private 6 | class OwnerAssigner 7 | extend T::Sig 8 | 9 | sig { params(globs_to_owning_team_map: GlobsToOwningTeamMap).returns(GlobsToOwningTeamMap) } 10 | def self.assign_owners(globs_to_owning_team_map) 11 | globs_to_owning_team_map.each_with_object({}) do |(glob, owner), mapping| 12 | # addresses the case where a directory name includes regex characters 13 | # such as `app/services/[test]/some_other_file.ts` 14 | mapping[glob] = owner if File.exist?(glob) 15 | Dir.glob(glob) do |file| 16 | mapping[file] ||= owner 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/code_ownership/private/ownership_mappers/directory_ownership.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: true 4 | 5 | module CodeOwnership 6 | module Private 7 | module OwnershipMappers 8 | class DirectoryOwnership 9 | extend T::Sig 10 | include Mapper 11 | 12 | CODEOWNERS_DIRECTORY_FILE_NAME = '.codeowner' 13 | 14 | @@directory_cache = T.let({}, T::Hash[String, T.nilable(CodeTeams::Team)]) # rubocop:disable Style/ClassVars 15 | 16 | sig do 17 | override.params(file: String) 18 | .returns(T.nilable(::CodeTeams::Team)) 19 | end 20 | def map_file_to_owner(file) 21 | map_file_to_relevant_owner(file) 22 | end 23 | 24 | sig do 25 | override.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 26 | end 27 | def update_cache(cache, files) 28 | globs_to_owner(files) 29 | end 30 | 31 | # 32 | # Directory ownership ignores the passed in files when generating code owners lines. 33 | # This is because Directory ownership knows that the fastest way to find code owners for directory based ownership 34 | # is to simply iterate over the directories and grab the owner, rather than iterating over each file just to get what directory it is in 35 | # In theory this means that we may generate code owners lines that cover files that are not in the passed in argument, 36 | # but in practice this is not of consequence because in reality we never really want to generate code owners for only a 37 | # subset of files, but rather we want code ownership for all files. 38 | # 39 | sig do 40 | override.params(files: T::Array[String]) 41 | .returns(T::Hash[String, ::CodeTeams::Team]) 42 | end 43 | def globs_to_owner(files) 44 | # The T.unsafe is because the upstream RBI is wrong for Pathname.glob 45 | T 46 | .unsafe(Pathname) 47 | .glob(File.join('**/', CODEOWNERS_DIRECTORY_FILE_NAME)) 48 | .map(&:cleanpath) 49 | .each_with_object({}) do |pathname, res| 50 | owner = owner_for_codeowners_file(pathname) 51 | glob = glob_for_codeowners_file(pathname) 52 | res[glob] = owner 53 | end 54 | end 55 | 56 | sig { override.returns(String) } 57 | def description 58 | 'Owner in .codeowner' 59 | end 60 | 61 | sig { override.void } 62 | def bust_caches! 63 | @@directory_cache = {} # rubocop:disable Style/ClassVars 64 | end 65 | 66 | private 67 | 68 | sig { params(codeowners_file: Pathname).returns(CodeTeams::Team) } 69 | def owner_for_codeowners_file(codeowners_file) 70 | raw_owner_value = File.foreach(codeowners_file).first.strip 71 | 72 | Private.find_team!( 73 | raw_owner_value, 74 | codeowners_file.to_s 75 | ) 76 | end 77 | 78 | # Takes a file and finds the relevant `.codeowner` file by walking up the directory 79 | # structure. Example, given `a/b/c.rb`, this looks for `a/b/.codeowner`, `a/.codeowner`, 80 | # and `.codeowner` in that order, stopping at the first file to actually exist. 81 | # If the provided file is a directory, it will look for `.codeowner` in that directory and then upwards. 82 | # We do additional caching so that we don't have to check for file existence every time. 83 | sig { params(file: String).returns(T.nilable(CodeTeams::Team)) } 84 | def map_file_to_relevant_owner(file) 85 | file_path = Pathname.new(file) 86 | team = T.let(nil, T.nilable(CodeTeams::Team)) 87 | 88 | if File.directory?(file) 89 | team = get_team_from_codeowners_file_within_directory(file_path) 90 | return team unless team.nil? 91 | end 92 | 93 | path_components = file_path.each_filename.to_a 94 | if file_path.absolute? 95 | path_components = ['/', *path_components] 96 | end 97 | 98 | (path_components.length - 1).downto(0).each do |i| 99 | team = get_team_from_codeowners_file_within_directory( 100 | Pathname.new(File.join(*T.unsafe(path_components[0...i]))) 101 | ) 102 | return team unless team.nil? 103 | end 104 | 105 | team 106 | end 107 | 108 | sig { params(directory: Pathname).returns(T.nilable(CodeTeams::Team)) } 109 | def get_team_from_codeowners_file_within_directory(directory) 110 | potential_codeowners_file = directory.join(CODEOWNERS_DIRECTORY_FILE_NAME) 111 | 112 | potential_codeowners_file_name = potential_codeowners_file.to_s 113 | 114 | team = nil 115 | if @@directory_cache.key?(potential_codeowners_file_name) 116 | team = @@directory_cache[potential_codeowners_file_name] 117 | elsif potential_codeowners_file.exist? 118 | team = owner_for_codeowners_file(potential_codeowners_file) 119 | 120 | @@directory_cache[potential_codeowners_file_name] = team 121 | else 122 | @@directory_cache[potential_codeowners_file_name] = nil 123 | end 124 | 125 | team 126 | end 127 | 128 | sig { params(codeowners_file: Pathname).returns(String) } 129 | def glob_for_codeowners_file(codeowners_file) 130 | unescaped = codeowners_file.dirname.cleanpath.join('**/**').to_s 131 | 132 | # Globs can contain certain regex characters, like "[" and "]". 133 | # However, when we are generating a glob from a .codeowner file, we 134 | # need to escape bracket characters and interpret them literally. 135 | # Otherwise the resulting glob will not actually match the directory 136 | # containing the .codeowner file. 137 | # 138 | # Example 139 | # file: "/some/[dir]/.codeowner" 140 | # unescaped: "/some/[dir]/**/**" 141 | # matches: "/some/d/file" 142 | # matches: "/some/i/file" 143 | # matches: "/some/r/file" 144 | # does not match!: "/some/[dir]/file" 145 | unescaped.gsub(/[\[\]]/) { |x| "\\#{x}" } 146 | end 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/code_ownership/private/ownership_mappers/file_annotations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: strict 4 | 5 | module CodeOwnership 6 | module Private 7 | module OwnershipMappers 8 | # Calculate, cache, and return a mapping of file names (relative to the root 9 | # of the repository) to team name. 10 | # 11 | # Example: 12 | # 13 | # { 14 | # 'app/models/company.rb' => Team.find('Setup & Onboarding'), 15 | # ... 16 | # } 17 | class FileAnnotations 18 | extend T::Sig 19 | include Mapper 20 | 21 | TEAM_PATTERN = T.let(%r{\A(?:#|//|-#) @team (?.*)\Z}.freeze, Regexp) 22 | DESCRIPTION = 'Annotations at the top of file' 23 | 24 | sig do 25 | override.params(file: String) 26 | .returns(T.nilable(::CodeTeams::Team)) 27 | end 28 | def map_file_to_owner(file) 29 | file_annotation_based_owner(file) 30 | end 31 | 32 | sig do 33 | override 34 | .params(files: T::Array[String]) 35 | .returns(T::Hash[String, ::CodeTeams::Team]) 36 | end 37 | def globs_to_owner(files) 38 | files.each_with_object({}) do |filename_relative_to_root, mapping| 39 | owner = file_annotation_based_owner(filename_relative_to_root) 40 | next unless owner 41 | 42 | escaped_filename = escaped_path_for_codeowners_file(filename_relative_to_root) 43 | mapping[escaped_filename] = owner 44 | end 45 | end 46 | 47 | sig do 48 | override.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 49 | end 50 | def update_cache(cache, files) 51 | # We map files to nil owners so that files whose annotation have been removed will be properly 52 | # overwritten (i.e. removed) from the cache. 53 | fileset = Set.new(files) 54 | updated_cache_for_files = globs_to_owner(files) 55 | cache.merge!(updated_cache_for_files) 56 | 57 | invalid_files = cache.keys.select do |file| 58 | # If a file is not tracked, it should be removed from the cache 59 | unescaped_file = unescaped_path_for_codeowners_file(file) 60 | !Private.file_tracked?(unescaped_file) || 61 | # If a file no longer has a file annotation (i.e. `globs_to_owner` doesn't map it) 62 | # it should be removed from the cache 63 | # We make sure to only apply this to the input files since otherwise `updated_cache_for_files.key?(file)` would always return `false` when files == [] 64 | (fileset.include?(file) && !updated_cache_for_files.key?(file)) 65 | end 66 | 67 | invalid_files.each do |invalid_file| 68 | cache.delete(invalid_file) 69 | end 70 | 71 | cache 72 | end 73 | 74 | sig { params(filename: String).returns(T.nilable(CodeTeams::Team)) } 75 | def file_annotation_based_owner(filename) 76 | # The annotation should be on line 1 but as of this comment 77 | # there's no linter installed to enforce that. We therefore check the 78 | # first line (the Ruby VM makes a single `read(1)` call for 8KB), 79 | # and if the annotation isn't in the first two lines we assume it 80 | # doesn't exist. 81 | 82 | begin 83 | line1 = File.foreach(filename).first 84 | rescue Errno::EISDIR, Errno::ENOENT 85 | # Ignore files that fail to read to avoid intermittent bugs. 86 | # Ignoring directories is needed because, e.g., Cypress screenshots 87 | # are saved to a folder with the test suite filename. 88 | return 89 | end 90 | 91 | return if !line1 92 | 93 | begin 94 | team = line1[TEAM_PATTERN, :team] 95 | rescue ArgumentError => e 96 | if e.message.include?('invalid byte sequence') 97 | team = nil 98 | else 99 | raise 100 | end 101 | end 102 | 103 | return unless team 104 | 105 | Private.find_team!( 106 | team, 107 | filename 108 | ) 109 | end 110 | 111 | sig { params(filename: String).void } 112 | def remove_file_annotation!(filename) 113 | if file_annotation_based_owner(filename) 114 | filepath = Pathname.new(filename) 115 | lines = filepath.read.split("\n") 116 | new_lines = lines.reject { |line| line[TEAM_PATTERN] } 117 | # We explicitly add a final new line since splitting by new line when reading the file lines 118 | # ignores new lines at the ends of files 119 | # We also remove leading new lines, since there is after a new line after an annotation 120 | new_file_contents = "#{new_lines.join("\n")}\n".gsub(/\A\n+/, '') 121 | filepath.write(new_file_contents) 122 | end 123 | end 124 | 125 | sig { override.returns(String) } 126 | def description 127 | DESCRIPTION 128 | end 129 | 130 | sig { override.void } 131 | def bust_caches!; end 132 | 133 | sig { params(filename: String).returns(String) } 134 | def escaped_path_for_codeowners_file(filename) 135 | # Globs can contain certain regex characters, like "[" and "]". 136 | # However, when we are generating a glob from a file annotation, we 137 | # need to escape bracket characters and interpret them literally. 138 | # Otherwise the resulting glob will not actually match the directory 139 | # containing the file. 140 | # 141 | # Example 142 | # filename: "/some/[xId]/myfile.tsx" 143 | # matches: "/some/1/file" 144 | # matches: "/some/2/file" 145 | # matches: "/some/3/file" 146 | # does not match!: "/some/[xId]/myfile.tsx" 147 | filename.gsub(/[\[\]]/) { |x| "\\#{x}" } 148 | end 149 | 150 | sig { params(filename: String).returns(String) } 151 | def unescaped_path_for_codeowners_file(filename) 152 | # Globs can contain certain regex characters, like "[" and "]". 153 | # We escape bracket characters and interpret them literally for 154 | # the CODEOWNERS file. However, we want to compare the unescaped 155 | # glob to the actual file path when we check if the file was deleted. 156 | filename.gsub(/\\([\[\]])/, '\1') 157 | end 158 | end 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/code_ownership/private/ownership_mappers/js_package_ownership.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: true 4 | 5 | module CodeOwnership 6 | module Private 7 | module OwnershipMappers 8 | class JsPackageOwnership 9 | extend T::Sig 10 | include Mapper 11 | 12 | @@package_json_cache = T.let({}, T::Hash[String, T.nilable(ParseJsPackages::Package)]) # rubocop:disable Style/ClassVars 13 | 14 | sig do 15 | override.params(file: String) 16 | .returns(T.nilable(::CodeTeams::Team)) 17 | end 18 | def map_file_to_owner(file) 19 | package = map_file_to_relevant_package(file) 20 | 21 | return nil if package.nil? 22 | 23 | owner_for_package(package) 24 | end 25 | 26 | sig do 27 | override.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 28 | end 29 | def update_cache(cache, files) 30 | globs_to_owner(files) 31 | end 32 | 33 | # 34 | # Package ownership ignores the passed in files when generating code owners lines. 35 | # This is because Package ownership knows that the fastest way to find code owners for package based ownership 36 | # is to simply iterate over the packages and grab the owner, rather than iterating over each file just to get what package it is in 37 | # In theory this means that we may generate code owners lines that cover files that are not in the passed in argument, 38 | # but in practice this is not of consequence because in reality we never really want to generate code owners for only a 39 | # subset of files, but rather we want code ownership for all files. 40 | # 41 | sig do 42 | override.params(files: T::Array[String]) 43 | .returns(T::Hash[String, ::CodeTeams::Team]) 44 | end 45 | def globs_to_owner(files) 46 | ParseJsPackages.all.each_with_object({}) do |package, res| 47 | owner = owner_for_package(package) 48 | next if owner.nil? 49 | 50 | res[package.directory.join('**/**').to_s] = owner 51 | end 52 | end 53 | 54 | sig { override.returns(String) } 55 | def description 56 | 'Owner metadata key in package.json' 57 | end 58 | 59 | sig { params(package: ParseJsPackages::Package).returns(T.nilable(CodeTeams::Team)) } 60 | def owner_for_package(package) 61 | raw_owner_value = package.metadata['owner'] 62 | return nil if !raw_owner_value 63 | 64 | Private.find_team!( 65 | raw_owner_value, 66 | package.name 67 | ) 68 | end 69 | 70 | sig { override.void } 71 | def bust_caches! 72 | @@package_json_cache = {} # rubocop:disable Style/ClassVars 73 | end 74 | 75 | private 76 | 77 | # takes a file and finds the relevant `package.json` file by walking up the directory 78 | # structure. Example, given `packages/a/b/c.rb`, this looks for `packages/a/b/package.json`, `packages/a/package.json`, 79 | # `packages/package.json`, and `package.json` in that order, stopping at the first file to actually exist. 80 | # We do additional caching so that we don't have to check for file existence every time 81 | sig { params(file: String).returns(T.nilable(ParseJsPackages::Package)) } 82 | def map_file_to_relevant_package(file) 83 | file_path = Pathname.new(file) 84 | path_components = file_path.each_filename.to_a.map { |path| Pathname.new(path) } 85 | 86 | (path_components.length - 1).downto(0).each do |i| 87 | potential_relative_path_name = T.must(path_components[0...i]).reduce(Pathname.new('')) { |built_path, path| built_path.join(path) } 88 | potential_package_json_path = potential_relative_path_name 89 | .join(ParseJsPackages::PACKAGE_JSON_NAME) 90 | 91 | potential_package_json_string = potential_package_json_path.to_s 92 | 93 | package = nil 94 | if @@package_json_cache.key?(potential_package_json_string) 95 | package = @@package_json_cache[potential_package_json_string] 96 | elsif potential_package_json_path.exist? 97 | package = ParseJsPackages::Package.from(potential_package_json_path) 98 | 99 | @@package_json_cache[potential_package_json_string] = package 100 | else 101 | @@package_json_cache[potential_package_json_string] = nil 102 | end 103 | 104 | return package unless package.nil? 105 | end 106 | 107 | nil 108 | end 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/code_ownership/private/ownership_mappers/package_ownership.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: true 4 | 5 | module CodeOwnership 6 | module Private 7 | module OwnershipMappers 8 | class PackageOwnership 9 | extend T::Sig 10 | include Mapper 11 | 12 | sig do 13 | override.params(file: String) 14 | .returns(T.nilable(::CodeTeams::Team)) 15 | end 16 | def map_file_to_owner(file) 17 | package = Packs.for_file(file) 18 | 19 | return nil if package.nil? 20 | 21 | owner_for_package(package) 22 | end 23 | 24 | # 25 | # Package ownership ignores the passed in files when generating code owners lines. 26 | # This is because Package ownership knows that the fastest way to find code owners for package based ownership 27 | # is to simply iterate over the packages and grab the owner, rather than iterating over each file just to get what package it is in 28 | # In theory this means that we may generate code owners lines that cover files that are not in the passed in argument, 29 | # but in practice this is not of consequence because in reality we never really want to generate code owners for only a 30 | # subset of files, but rather we want code ownership for all files. 31 | # 32 | sig do 33 | override.params(files: T::Array[String]) 34 | .returns(T::Hash[String, ::CodeTeams::Team]) 35 | end 36 | def globs_to_owner(files) 37 | Packs.all.each_with_object({}) do |package, res| 38 | owner = owner_for_package(package) 39 | next if owner.nil? 40 | 41 | res[package.relative_path.join('**/**').to_s] = owner 42 | end 43 | end 44 | 45 | sig { override.returns(String) } 46 | def description 47 | 'Owner metadata key in package.yml' 48 | end 49 | 50 | sig do 51 | override.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 52 | end 53 | def update_cache(cache, files) 54 | globs_to_owner(files) 55 | end 56 | 57 | sig { params(package: Packs::Pack).returns(T.nilable(CodeTeams::Team)) } 58 | def owner_for_package(package) 59 | raw_owner_value = package.raw_hash['owner'] || package.metadata['owner'] 60 | return nil if !raw_owner_value 61 | 62 | Private.find_team!( 63 | raw_owner_value, 64 | package.yml.to_s 65 | ) 66 | end 67 | 68 | sig { override.void } 69 | def bust_caches!; end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/code_ownership/private/ownership_mappers/team_globs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: true 4 | 5 | module CodeOwnership 6 | module Private 7 | module OwnershipMappers 8 | class TeamGlobs 9 | extend T::Sig 10 | include Mapper 11 | include Validator 12 | 13 | @@map_files_to_owners = T.let(@map_files_to_owners, T.nilable(T::Hash[String, ::CodeTeams::Team])) # rubocop:disable Style/ClassVars 14 | @@map_files_to_owners = {} # rubocop:disable Style/ClassVars 15 | 16 | sig do 17 | returns(T::Hash[String, ::CodeTeams::Team]) 18 | end 19 | def map_files_to_owners 20 | return @@map_files_to_owners if @@map_files_to_owners&.keys && @@map_files_to_owners.keys.count.positive? 21 | 22 | @@map_files_to_owners = CodeTeams.all.each_with_object({}) do |team, map| # rubocop:disable Style/ClassVars 23 | code_team = TeamPlugins::Ownership.for(team) 24 | 25 | (Dir.glob(code_team.owned_globs) - Dir.glob(code_team.unowned_globs)).each do |filename| 26 | map[filename] = team 27 | end 28 | end 29 | end 30 | 31 | class MappingContext < T::Struct 32 | const :glob, String 33 | const :team, CodeTeams::Team 34 | end 35 | 36 | class GlobOverlap < T::Struct 37 | extend T::Sig 38 | 39 | const :mapping_contexts, T::Array[MappingContext] 40 | 41 | sig { returns(String) } 42 | def description 43 | # These are sorted only to prevent non-determinism in output between local and CI environments. 44 | sorted_contexts = mapping_contexts.sort_by { |context| context.team.config_yml.to_s } 45 | description_args = sorted_contexts.map do |context| 46 | "`#{context.glob}` (from `#{context.team.config_yml}`)" 47 | end 48 | 49 | description_args.join(', ') 50 | end 51 | end 52 | 53 | sig do 54 | returns(T::Array[GlobOverlap]) 55 | end 56 | def find_overlapping_globs 57 | mapped_files = T.let({}, T::Hash[String, T::Array[MappingContext]]) 58 | CodeTeams.all.each do |team| 59 | code_team = TeamPlugins::Ownership.for(team) 60 | 61 | code_team.owned_globs.each do |glob| 62 | Dir.glob(glob) do |filename| 63 | mapped_files[filename] ||= [] 64 | T.must(mapped_files[filename]) << MappingContext.new(glob: glob, team: team) 65 | end 66 | end 67 | 68 | # Remove anything that is unowned, globbing them all at once 69 | Dir.glob(code_team.unowned_globs) do |filename| 70 | mapped_files.reject! { |key, value| key == filename && value.any? { |context| context.team == team } } 71 | end 72 | end 73 | 74 | overlaps = T.let([], T::Array[GlobOverlap]) 75 | mapped_files.each_value do |mapping_contexts| 76 | if mapping_contexts.count > 1 77 | overlaps << GlobOverlap.new(mapping_contexts: mapping_contexts) 78 | end 79 | end 80 | 81 | overlaps.uniq do |glob_overlap| 82 | glob_overlap.mapping_contexts.map do |context| 83 | [context.glob, context.team.name] 84 | end 85 | end 86 | end 87 | 88 | sig do 89 | override.params(file: String) 90 | .returns(T.nilable(::CodeTeams::Team)) 91 | end 92 | def map_file_to_owner(file) 93 | map_files_to_owners[file] 94 | end 95 | 96 | sig do 97 | override.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 98 | end 99 | def update_cache(cache, files) 100 | globs_to_owner(files) 101 | end 102 | 103 | sig do 104 | override.params(files: T::Array[String]) 105 | .returns(T::Hash[String, ::CodeTeams::Team]) 106 | end 107 | def globs_to_owner(files) 108 | CodeTeams.all.each_with_object({}) do |team, map| 109 | TeamPlugins::Ownership.for(team).owned_globs.each do |owned_glob| 110 | map[owned_glob] = team 111 | end 112 | end 113 | end 114 | 115 | sig { override.void } 116 | def bust_caches! 117 | @@map_files_to_owners = {} # rubocop:disable Style/ClassVars 118 | end 119 | 120 | sig { override.returns(String) } 121 | def description 122 | 'Team-specific owned globs' 123 | end 124 | 125 | sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } 126 | def validation_errors(files:, autocorrect: true, stage_changes: true) 127 | overlapping_globs = OwnershipMappers::TeamGlobs.new.find_overlapping_globs 128 | 129 | errors = T.let([], T::Array[String]) 130 | 131 | if overlapping_globs.any? 132 | errors << <<~MSG 133 | `owned_globs` cannot overlap between teams. The following globs overlap: 134 | 135 | #{overlapping_globs.map { |overlap| "- #{overlap.description}" }.join("\n")} 136 | MSG 137 | end 138 | 139 | errors 140 | end 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/code_ownership/private/ownership_mappers/team_yml_ownership.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: true 4 | 5 | module CodeOwnership 6 | module Private 7 | module OwnershipMappers 8 | class TeamYmlOwnership 9 | extend T::Sig 10 | include Mapper 11 | 12 | @@map_files_to_owners = T.let(@map_files_to_owners, T.nilable(T::Hash[String, ::CodeTeams::Team])) # rubocop:disable Style/ClassVars 13 | @@map_files_to_owners = {} # rubocop:disable Style/ClassVars 14 | 15 | sig do 16 | params(files: T::Array[String]) 17 | .returns(T::Hash[String, ::CodeTeams::Team]) 18 | end 19 | def map_files_to_owners(files) 20 | return @@map_files_to_owners if @@map_files_to_owners&.keys && @@map_files_to_owners.keys.count.positive? 21 | 22 | @@map_files_to_owners = CodeTeams.all.each_with_object({}) do |team, map| # rubocop:disable Style/ClassVars 23 | map[team.config_yml] = team 24 | end 25 | end 26 | 27 | sig do 28 | override.params(file: String) 29 | .returns(T.nilable(::CodeTeams::Team)) 30 | end 31 | def map_file_to_owner(file) 32 | map_files_to_owners([file])[file] 33 | end 34 | 35 | sig do 36 | override.params(files: T::Array[String]) 37 | .returns(T::Hash[String, ::CodeTeams::Team]) 38 | end 39 | def globs_to_owner(files) 40 | CodeTeams.all.each_with_object({}) do |team, map| 41 | map[team.config_yml] = team 42 | end 43 | end 44 | 45 | sig { override.void } 46 | def bust_caches! 47 | @@map_files_to_owners = {} # rubocop:disable Style/ClassVars 48 | end 49 | 50 | sig do 51 | override.params(cache: GlobsToOwningTeamMap, files: T::Array[String]).returns(GlobsToOwningTeamMap) 52 | end 53 | def update_cache(cache, files) 54 | globs_to_owner(files) 55 | end 56 | 57 | sig { override.returns(String) } 58 | def description 59 | 'Team YML ownership' 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/code_ownership/private/parse_js_packages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: true 4 | 5 | module CodeOwnership 6 | module Private 7 | # Modeled off of ParsePackwerk 8 | module ParseJsPackages 9 | extend T::Sig 10 | 11 | ROOT_PACKAGE_NAME = 'root' 12 | PACKAGE_JSON_NAME = T.let('package.json', String) 13 | METADATA = 'metadata' 14 | 15 | class Package < T::Struct 16 | extend T::Sig 17 | 18 | const :name, String 19 | const :metadata, T::Hash[String, T.untyped] 20 | 21 | sig { params(pathname: Pathname).returns(Package) } 22 | def self.from(pathname) 23 | package_loaded_json = JSON.parse(pathname.read) 24 | 25 | package_name = if pathname.dirname == Pathname.new('.') 26 | ROOT_PACKAGE_NAME 27 | else 28 | pathname.dirname.cleanpath.to_s 29 | end 30 | 31 | new( 32 | name: package_name, 33 | metadata: package_loaded_json[METADATA] || {} 34 | ) 35 | rescue JSON::ParserError => e 36 | error_message = <<~MESSAGE 37 | #{e.inspect} 38 | 39 | #{pathname} has invalid JSON, so code ownership cannot be determined. 40 | 41 | Please either make the JSON in that file valid or specify `js_package_paths` in config/code_ownership.yml. 42 | MESSAGE 43 | 44 | raise InvalidCodeOwnershipConfigurationError, error_message 45 | end 46 | 47 | sig { returns(Pathname) } 48 | def directory 49 | root_pathname = Pathname.new('.') 50 | name == ROOT_PACKAGE_NAME ? root_pathname.cleanpath : root_pathname.join(name).cleanpath 51 | end 52 | end 53 | 54 | sig do 55 | returns(T::Array[Package]) 56 | end 57 | def self.all 58 | package_glob_patterns = Private.configuration.js_package_paths.map do |pathspec| 59 | File.join(pathspec, PACKAGE_JSON_NAME) 60 | end 61 | 62 | # The T.unsafe is because the upstream RBI is wrong for Pathname.glob 63 | T.unsafe(Pathname).glob(package_glob_patterns).map(&:cleanpath).map do |path| 64 | Package.from(path) 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/code_ownership/private/permit_pack_owner_top_level_key.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | # frozen_string_literal: true 3 | 4 | require 'packwerk' 5 | 6 | module CodeOwnership 7 | module Private 8 | class PackOwnershipValidator 9 | extend T::Sig 10 | include Packwerk::Validator 11 | 12 | sig { override.params(package_set: Packwerk::PackageSet, configuration: Packwerk::Configuration).returns(Result) } 13 | def call(package_set, configuration) 14 | Result.new(ok: true) 15 | end 16 | 17 | sig { override.returns(T::Array[String]) } 18 | def permitted_keys 19 | %w[owner] 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/code_ownership/private/team_plugins/github.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | module CodeOwnership 4 | module Private 5 | module TeamPlugins 6 | class Github < CodeTeams::Plugin 7 | extend T::Sig 8 | extend T::Helpers 9 | 10 | GithubStruct = Struct.new(:team, :do_not_add_to_codeowners_file) 11 | 12 | sig { returns(GithubStruct) } 13 | def github 14 | raw_github = @team.raw_hash['github'] || {} 15 | 16 | GithubStruct.new( 17 | raw_github['team'], 18 | raw_github['do_not_add_to_codeowners_file'] || false 19 | ) 20 | end 21 | 22 | sig { override.params(teams: T::Array[CodeTeams::Team]).returns(T::Array[String]) } 23 | def self.validation_errors(teams) 24 | all_github_teams = teams.flat_map { |team| self.for(team).github.team }.compact 25 | 26 | teams_used_more_than_once = all_github_teams.tally.select do |_team, count| 27 | count > 1 28 | end 29 | 30 | errors = T.let([], T::Array[String]) 31 | 32 | if require_github_teams? 33 | missing_github_teams = teams.select { |team| self.for(team).github.team.nil? } 34 | 35 | if missing_github_teams.any? 36 | errors << <<~ERROR 37 | The following teams are missing `github.team` entries: 38 | 39 | #{missing_github_teams.map(&:config_yml).join("\n")} 40 | ERROR 41 | end 42 | end 43 | 44 | if teams_used_more_than_once.any? 45 | errors << <<~ERROR 46 | The following teams are specified multiple times: 47 | Each code team must have a unique GitHub team in order to write the CODEOWNERS file correctly. 48 | 49 | #{teams_used_more_than_once.keys.join("\n")} 50 | ERROR 51 | end 52 | 53 | errors 54 | end 55 | 56 | sig { returns(T::Boolean) } 57 | def self.require_github_teams? 58 | CodeOwnership.configuration.require_github_teams 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/code_ownership/private/team_plugins/ownership.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | module CodeOwnership 4 | module Private 5 | module TeamPlugins 6 | class Ownership < CodeTeams::Plugin 7 | extend T::Sig 8 | extend T::Helpers 9 | 10 | sig { returns(T::Array[String]) } 11 | def owned_globs 12 | @team.raw_hash['owned_globs'] || [] 13 | end 14 | 15 | sig { returns(T::Array[String]) } 16 | def unowned_globs 17 | @team.raw_hash['unowned_globs'] || [] 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/code_ownership/private/validations/files_have_owners.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module CodeOwnership 4 | module Private 5 | module Validations 6 | class FilesHaveOwners 7 | extend T::Sig 8 | extend T::Helpers 9 | include Validator 10 | 11 | sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } 12 | def validation_errors(files:, autocorrect: true, stage_changes: true) 13 | cache = Private.glob_cache 14 | file_mappings = cache.mapper_descriptions_that_map_files(files) 15 | files_not_mapped_at_all = file_mappings.select do |_file, mapper_descriptions| 16 | mapper_descriptions.count.zero? 17 | end 18 | 19 | errors = T.let([], T::Array[String]) 20 | 21 | if files_not_mapped_at_all.any? 22 | errors << <<~MSG 23 | Some files are missing ownership: 24 | 25 | #{files_not_mapped_at_all.map { |file, _mappers| "- #{file}" }.join("\n")} 26 | MSG 27 | end 28 | 29 | errors 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/code_ownership/private/validations/files_have_unique_owners.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module CodeOwnership 4 | module Private 5 | module Validations 6 | class FilesHaveUniqueOwners 7 | extend T::Sig 8 | extend T::Helpers 9 | include Validator 10 | 11 | sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } 12 | def validation_errors(files:, autocorrect: true, stage_changes: true) 13 | cache = Private.glob_cache 14 | file_mappings = cache.mapper_descriptions_that_map_files(files) 15 | files_mapped_by_multiple_mappers = file_mappings.select do |_file, mapper_descriptions| 16 | mapper_descriptions.count > 1 17 | end 18 | 19 | errors = T.let([], T::Array[String]) 20 | 21 | if files_mapped_by_multiple_mappers.any? 22 | errors << <<~MSG 23 | Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways. 24 | 25 | #{files_mapped_by_multiple_mappers.map { |file, descriptions| "- #{file} (#{descriptions.to_a.join(', ')})" }.join("\n")} 26 | MSG 27 | end 28 | 29 | errors 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/code_ownership/private/validations/github_codeowners_up_to_date.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module CodeOwnership 4 | module Private 5 | module Validations 6 | class GithubCodeownersUpToDate 7 | extend T::Sig 8 | extend T::Helpers 9 | include Validator 10 | 11 | sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } 12 | def validation_errors(files:, autocorrect: true, stage_changes: true) 13 | return [] if Private.configuration.skip_codeowners_validation 14 | 15 | actual_content_lines = CodeownersFile.actual_contents_lines 16 | expected_content_lines = CodeownersFile.expected_contents_lines 17 | 18 | codeowners_up_to_date = actual_content_lines == expected_content_lines 19 | errors = T.let([], T::Array[String]) 20 | 21 | if !codeowners_up_to_date 22 | if autocorrect 23 | CodeownersFile.write! 24 | if stage_changes 25 | `git add #{CodeownersFile.path}` 26 | end 27 | # If there is no current file or its empty, display a shorter message. 28 | elsif actual_content_lines == [''] 29 | errors << <<~CODEOWNERS_ERROR 30 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 31 | CODEOWNERS_ERROR 32 | else 33 | missing_lines = expected_content_lines - actual_content_lines 34 | extra_lines = actual_content_lines - expected_content_lines 35 | 36 | missing_lines_text = if missing_lines.any? 37 | <<~COMMENT 38 | CODEOWNERS should contain the following lines, but does not: 39 | #{missing_lines.map { |line| "- \"#{line}\"" }.join("\n")} 40 | COMMENT 41 | end 42 | 43 | extra_lines_text = if extra_lines.any? 44 | <<~COMMENT 45 | CODEOWNERS should not contain the following lines, but it does: 46 | #{extra_lines.map { |line| "- \"#{line}\"" }.join("\n")} 47 | COMMENT 48 | end 49 | 50 | diff_text = if missing_lines_text && extra_lines_text 51 | "#{missing_lines_text}\n#{extra_lines_text}".chomp 52 | elsif missing_lines_text 53 | missing_lines_text 54 | elsif extra_lines_text 55 | extra_lines_text 56 | else 57 | <<~TEXT 58 | There may be extra lines, or lines are out of order. 59 | You can try to regenerate the CODEOWNERS file from scratch: 60 | 1) `rm .github/CODEOWNERS` 61 | 2) `bin/codeownership validate` 62 | TEXT 63 | end 64 | 65 | errors << <<~CODEOWNERS_ERROR 66 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 67 | 68 | #{diff_text.chomp} 69 | CODEOWNERS_ERROR 70 | end 71 | end 72 | 73 | errors 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/code_ownership/validator.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module CodeOwnership 4 | module Validator 5 | extend T::Sig 6 | extend T::Helpers 7 | 8 | interface! 9 | 10 | sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } 11 | def validation_errors(files:, autocorrect: true, stage_changes: true); end 12 | 13 | class << self 14 | extend T::Sig 15 | 16 | sig { params(base: T::Class[Validator]).void } 17 | def included(base) 18 | @validators ||= T.let(@validators, T.nilable(T::Array[T::Class[Validator]])) 19 | @validators ||= [] 20 | @validators << base 21 | end 22 | 23 | sig { returns(T::Array[Validator]) } 24 | def all 25 | (@validators || []).map(&:new) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /sorbet/config: -------------------------------------------------------------------------------- 1 | --dir 2 | . 3 | --ignore=/spec 4 | --ignore=/vendor/bundle 5 | --enable-experimental-requires-ancestor 6 | -------------------------------------------------------------------------------- /sorbet/rbi/gems/code_teams@1.0.0.rbi: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | # DO NOT EDIT MANUALLY 4 | # This is an autogenerated file for types exported from the `code_teams` gem. 5 | # Please instead update this file by running `bin/tapioca gem code_teams`. 6 | 7 | module CodeTeams 8 | class << self 9 | sig { returns(T::Array[::CodeTeams::Team]) } 10 | def all; end 11 | 12 | sig { void } 13 | def bust_caches!; end 14 | 15 | sig { params(name: ::String).returns(T.nilable(::CodeTeams::Team)) } 16 | def find(name); end 17 | 18 | sig { params(dir: ::String).returns(T::Array[::CodeTeams::Team]) } 19 | def for_directory(dir); end 20 | 21 | sig { params(string: ::String).returns(::String) } 22 | def tag_value_for(string); end 23 | 24 | sig { params(teams: T::Array[::CodeTeams::Team]).returns(T::Array[::String]) } 25 | def validation_errors(teams); end 26 | end 27 | end 28 | 29 | class CodeTeams::IncorrectPublicApiUsageError < ::StandardError; end 30 | 31 | class CodeTeams::Plugin 32 | abstract! 33 | 34 | sig { params(team: ::CodeTeams::Team).void } 35 | def initialize(team); end 36 | 37 | class << self 38 | sig { returns(T::Array[T.class_of(CodeTeams::Plugin)]) } 39 | def all_plugins; end 40 | 41 | sig { params(team: ::CodeTeams::Team).returns(T.attached_class) } 42 | def for(team); end 43 | 44 | sig { params(base: T.untyped).void } 45 | def inherited(base); end 46 | 47 | sig { params(team: ::CodeTeams::Team, key: ::String).returns(::String) } 48 | def missing_key_error_message(team, key); end 49 | 50 | sig { params(teams: T::Array[::CodeTeams::Team]).returns(T::Array[::String]) } 51 | def validation_errors(teams); end 52 | 53 | private 54 | 55 | sig { params(team: ::CodeTeams::Team).returns(T.attached_class) } 56 | def register_team(team); end 57 | 58 | sig { returns(T::Hash[T.nilable(::String), T::Hash[::Class, ::CodeTeams::Plugin]]) } 59 | def registry; end 60 | end 61 | end 62 | 63 | module CodeTeams::Plugins; end 64 | 65 | class CodeTeams::Plugins::Identity < ::CodeTeams::Plugin 66 | sig { returns(::CodeTeams::Plugins::Identity::IdentityStruct) } 67 | def identity; end 68 | 69 | class << self 70 | sig { override.params(teams: T::Array[::CodeTeams::Team]).returns(T::Array[::String]) } 71 | def validation_errors(teams); end 72 | end 73 | end 74 | 75 | class CodeTeams::Plugins::Identity::IdentityStruct < ::Struct 76 | def name; end 77 | def name=(_); end 78 | 79 | class << self 80 | def [](*_arg0); end 81 | def inspect; end 82 | def members; end 83 | def new(*_arg0); end 84 | end 85 | end 86 | 87 | class CodeTeams::Team 88 | sig { params(config_yml: T.nilable(::String), raw_hash: T::Hash[T.untyped, T.untyped]).void } 89 | def initialize(config_yml:, raw_hash:); end 90 | 91 | sig { params(other: ::Object).returns(T::Boolean) } 92 | def ==(other); end 93 | 94 | sig { returns(T.nilable(::String)) } 95 | def config_yml; end 96 | 97 | def eql?(*args, &blk); end 98 | 99 | sig { returns(::Integer) } 100 | def hash; end 101 | 102 | sig { returns(::String) } 103 | def name; end 104 | 105 | sig { returns(T::Hash[T.untyped, T.untyped]) } 106 | def raw_hash; end 107 | 108 | sig { returns(::String) } 109 | def to_tag; end 110 | 111 | class << self 112 | sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(::CodeTeams::Team) } 113 | def from_hash(raw_hash); end 114 | 115 | sig { params(config_yml: ::String).returns(::CodeTeams::Team) } 116 | def from_yml(config_yml); end 117 | end 118 | end 119 | 120 | CodeTeams::UNKNOWN_TEAM_STRING = T.let(T.unsafe(nil), String) 121 | -------------------------------------------------------------------------------- /sorbet/rbi/gems/packs@0.0.2.rbi: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | # DO NOT EDIT MANUALLY 4 | # This is an autogenerated file for types exported from the `packs` gem. 5 | # Please instead update this file by running `bin/tapioca gem packs`. 6 | 7 | module Packs 8 | class << self 9 | sig { returns(T::Array[::Packs::Pack]) } 10 | def all; end 11 | 12 | sig { void } 13 | def bust_cache!; end 14 | 15 | sig { returns(::Packs::Configuration) } 16 | def config; end 17 | 18 | sig { params(blk: T.proc.params(arg0: ::Packs::Configuration).void).void } 19 | def configure(&blk); end 20 | 21 | sig { params(name: ::String).returns(T.nilable(::Packs::Pack)) } 22 | def find(name); end 23 | 24 | sig { params(file_path: T.any(::Pathname, ::String)).returns(T.nilable(::Packs::Pack)) } 25 | def for_file(file_path); end 26 | 27 | private 28 | 29 | sig { returns(T::Array[::Pathname]) } 30 | def package_glob_patterns; end 31 | 32 | sig { returns(T::Hash[::String, ::Packs::Pack]) } 33 | def packs_by_name; end 34 | end 35 | end 36 | 37 | class Packs::Configuration 38 | sig { void } 39 | def initialize; end 40 | 41 | sig { returns(T::Array[::Pathname]) } 42 | def roots; end 43 | 44 | sig { params(roots: T::Array[::String]).void } 45 | def roots=(roots); end 46 | end 47 | 48 | Packs::PACKAGE_FILE = T.let(T.unsafe(nil), String) 49 | 50 | class Packs::Pack < ::T::Struct 51 | const :name, ::String 52 | const :path, ::Pathname 53 | const :raw_hash, T::Hash[T.untyped, T.untyped] 54 | const :relative_path, ::Pathname 55 | 56 | sig { returns(::String) } 57 | def last_name; end 58 | 59 | sig { returns(T::Hash[T.untyped, T.untyped]) } 60 | def metadata; end 61 | 62 | sig { returns(::Pathname) } 63 | def yml; end 64 | 65 | class << self 66 | sig { params(package_yml_absolute_path: ::Pathname).returns(::Packs::Pack) } 67 | def from(package_yml_absolute_path); end 68 | 69 | def inherited(s); end 70 | end 71 | end 72 | 73 | module Packs::Private 74 | class << self 75 | sig { returns(::Pathname) } 76 | def root; end 77 | end 78 | end 79 | 80 | Packs::ROOTS = T.let(T.unsafe(nil), Array) 81 | -------------------------------------------------------------------------------- /sorbet/rbi/gems/packwerk@3.0.1.rbi: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | # DO NOT EDIT MANUALLY 4 | # This is an autogenerated file for types exported from the `packwerk` gem. 5 | # Please instead update this file by running `bin/tapioca gem packwerk`. 6 | 7 | 8 | # @abstract Subclasses must implement the `abstract` methods below. 9 | # 10 | # source://packwerk//lib/packwerk/validator.rb#9 11 | module Packwerk::Validator 12 | 13 | abstract! 14 | 15 | # @abstract 16 | # 17 | # source://packwerk//lib/packwerk/validator.rb#36 18 | sig do 19 | abstract 20 | .params( 21 | package_set: Packwerk::PackageSet, 22 | configuration: ::Packwerk::Configuration 23 | ).returns(::Packwerk::Validator::Result) 24 | end 25 | def call(package_set, configuration); end 26 | 27 | # source://packwerk//lib/packwerk/validator.rb#67 28 | sig do 29 | params( 30 | results: T::Array[::Packwerk::Validator::Result], 31 | separator: ::String, 32 | before_errors: ::String, 33 | after_errors: ::String 34 | ).returns(::Packwerk::Validator::Result) 35 | end 36 | def merge_results(results, separator: T.unsafe(nil), before_errors: T.unsafe(nil), after_errors: T.unsafe(nil)); end 37 | 38 | # source://packwerk//lib/packwerk/validator.rb#55 39 | sig { params(configuration: ::Packwerk::Configuration).returns(T.any(::String, T::Array[::String])) } 40 | def package_glob(configuration); end 41 | 42 | # source://packwerk//lib/packwerk/validator.rb#48 43 | sig do 44 | params( 45 | configuration: ::Packwerk::Configuration, 46 | glob_pattern: T.nilable(T.any(::String, T::Array[::String])) 47 | ).returns(T::Array[::String]) 48 | end 49 | def package_manifests(configuration, glob_pattern = T.unsafe(nil)); end 50 | 51 | # source://packwerk//lib/packwerk/validator.rb#40 52 | sig { params(configuration: ::Packwerk::Configuration, setting: T.untyped).returns(T.untyped) } 53 | def package_manifests_settings_for(configuration, setting); end 54 | 55 | # @abstract 56 | # 57 | # source://packwerk//lib/packwerk/validator.rb#32 58 | sig { abstract.returns(T::Array[::String]) } 59 | def permitted_keys; end 60 | 61 | # source://packwerk//lib/packwerk/validator.rb#86 62 | sig { params(configuration: ::Packwerk::Configuration, path: ::String).returns(::Pathname) } 63 | def relative_path(configuration, path); end 64 | 65 | class << self 66 | # source://packwerk//lib/packwerk/validator.rb#26 67 | sig { returns(T::Array[::Packwerk::Validator]) } 68 | def all; end 69 | 70 | # source://packwerk//lib/packwerk/validator.rb#19 71 | sig { params(base: ::Class).void } 72 | def included(base); end 73 | end 74 | end 75 | 76 | # source://packwerk//lib/packwerk/validator/result.rb#6 77 | class Packwerk::Validator::Result < ::T::Struct 78 | const :ok, T::Boolean 79 | const :error_value, T.nilable(::String) 80 | 81 | # source://packwerk//lib/packwerk/validator/result.rb#13 82 | sig { returns(T::Boolean) } 83 | def ok?; end 84 | 85 | class << self 86 | # source://sorbet-runtime/0.5.10821/lib/types/struct.rb#13 87 | def inherited(s); end 88 | end 89 | end 90 | 91 | class Packwerk::PackageSet; end 92 | class Packwerk::Configuration; end 93 | -------------------------------------------------------------------------------- /sorbet/rbi/manual.rbi: -------------------------------------------------------------------------------- 1 | class Hash 2 | def to_json; end 3 | end 4 | -------------------------------------------------------------------------------- /sorbet/rbi/todo.rbi: -------------------------------------------------------------------------------- 1 | # This file is autogenerated. Do not edit it by hand. Regenerate it with: 2 | # srb rbi todo 3 | 4 | # typed: strong 5 | module ::RSpec; end 6 | module ::SequoiaTree; end 7 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/cli_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CodeOwnership::Cli do 2 | subject { CodeOwnership::Cli.run!(argv) } 3 | 4 | describe 'validate' do 5 | let(:argv) { ['validate'] } 6 | let(:owned_globs) { nil } 7 | 8 | before do 9 | write_configuration(owned_globs: owned_globs) 10 | write_file('app/services/my_file.rb') 11 | write_file('frontend/javascripts/my_file.jsx') 12 | end 13 | 14 | context 'when run without arguments' do 15 | it 'runs validations with the right defaults' do 16 | expect(CodeOwnership).to receive(:validate!) do |args| 17 | expect(args[:autocorrect]).to eq true 18 | expect(args[:stage_changes]).to eq true 19 | expect(args[:files]).to be_nil 20 | end 21 | subject 22 | end 23 | end 24 | 25 | context 'with --diff argument' do 26 | let(:argv) { ['validate', '--diff'] } 27 | 28 | before do 29 | allow(ENV).to receive(:fetch).and_call_original 30 | allow(ENV).to receive(:fetch).with('CODEOWNERS_GIT_STAGED_FILES').and_return('app/services/my_file.rb') 31 | end 32 | 33 | context 'when there are multiple owned_globs' do 34 | let(:owned_globs) { ['app/*/**', 'lib/*/**'] } 35 | 36 | it 'validates the tracked file' do 37 | expect { subject }.to raise_error CodeOwnership::InvalidCodeOwnershipConfigurationError 38 | end 39 | end 40 | end 41 | end 42 | 43 | describe 'for_file' do 44 | before do 45 | write_configuration 46 | 47 | write_file('app/services/my_file.rb') 48 | write_file('config/teams/my_team.yml', <<~YML) 49 | name: My Team 50 | owned_globs:#{' '} 51 | - 'app/**/*.rb' 52 | YML 53 | end 54 | 55 | context 'when run with no flags' do 56 | context 'when run with one file' do 57 | let(:argv) { ['for_file', 'app/services/my_file.rb'] } 58 | 59 | it 'outputs the team info in human readable format' do 60 | expect(CodeOwnership::Cli).to receive(:puts).with(<<~MSG) 61 | Team: My Team 62 | Team YML: config/teams/my_team.yml 63 | MSG 64 | subject 65 | end 66 | end 67 | 68 | context 'when run with no files' do 69 | let(:argv) { ['for_file'] } 70 | 71 | it 'outputs the team info in human readable format' do 72 | expect { subject }.to raise_error 'Please pass in one file. Use `bin/codeownership for_file --help` for more info' 73 | end 74 | end 75 | 76 | context 'when run with multiple files' do 77 | let(:argv) { ['for_file', 'app/services/my_file.rb', 'app/services/my_file2.rb'] } 78 | 79 | it 'outputs the team info in human readable format' do 80 | expect { subject }.to raise_error 'Please pass in one file. Use `bin/codeownership for_file --help` for more info' 81 | end 82 | end 83 | end 84 | 85 | context 'when run with --json' do 86 | let(:argv) { ['for_file', '--json', 'app/services/my_file.rb'] } 87 | 88 | context 'when run with one file' do 89 | it 'outputs JSONified information to the console' do 90 | json = { 91 | team_name: 'My Team', 92 | team_yml: 'config/teams/my_team.yml' 93 | } 94 | expect(CodeOwnership::Cli).to receive(:puts).with(json.to_json) 95 | subject 96 | end 97 | end 98 | 99 | context 'when run with no files' do 100 | let(:argv) { ['for_file', '--json'] } 101 | 102 | it 'outputs the team info in human readable format' do 103 | expect { subject }.to raise_error 'Please pass in one file. Use `bin/codeownership for_file --help` for more info' 104 | end 105 | end 106 | 107 | context 'when run with multiple files' do 108 | let(:argv) { ['for_file', 'app/services/my_file.rb', 'app/services/my_file2.rb'] } 109 | 110 | it 'outputs the team info in human readable format' do 111 | expect { subject }.to raise_error 'Please pass in one file. Use `bin/codeownership for_file --help` for more info' 112 | end 113 | end 114 | end 115 | end 116 | 117 | describe 'using unknown command' do 118 | let(:argv) { ['some_command'] } 119 | 120 | it 'outputs help text' do 121 | expect(CodeOwnership::Cli).to receive(:puts).with("'some_command' is not a code_ownership command. See `bin/codeownership help`.") 122 | subject 123 | end 124 | end 125 | 126 | describe 'passing in no command' do 127 | let(:argv) { [] } 128 | 129 | it 'outputs help text' do 130 | expected = <<~EXPECTED 131 | Usage: bin/codeownership 132 | 133 | Subcommands: 134 | validate - run all validations 135 | for_file - find code ownership for a single file 136 | for_team - find code ownership information for a team 137 | help - display help information about code_ownership 138 | EXPECTED 139 | expect(CodeOwnership::Cli).to receive(:puts).with(expected) 140 | subject 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/codeowners_file_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::CodeownersFile do 3 | describe '.path' do 4 | subject { described_class.path } 5 | 6 | context 'when codeowners_path is set in the configuration' do 7 | let(:configuration) do 8 | Configuration.new( 9 | owned_globs: [], 10 | unowned_globs: [], 11 | js_package_paths: [], 12 | unbuilt_gems_path: nil, 13 | skip_codeowners_validation: false, 14 | raw_hash: {}, 15 | require_github_teams: false, 16 | codeowners_path: path 17 | ) 18 | end 19 | 20 | before do 21 | allow(CodeOwnership).to receive(:configuration).and_return(configuration) 22 | end 23 | 24 | context "to 'foo'" do 25 | let(:path) { 'foo' } 26 | 27 | it 'uses the environment variable' do 28 | expect(subject).to eq(Pathname.pwd.join('foo', 'CODEOWNERS')) 29 | end 30 | end 31 | 32 | context 'to empty' do 33 | let(:path) { '' } 34 | 35 | it 'uses the environment variable' do 36 | expect(subject).to eq(Pathname.pwd.join('CODEOWNERS')) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/extension_loader_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | # We do not bust the cache here so that we only load the extension once! 3 | RSpec.describe Private::ExtensionLoader, :do_not_bust_cache do 4 | let(:codeowners_validation) { Private::Validations::GithubCodeownersUpToDate } 5 | 6 | before do 7 | write_configuration('require' => ['./lib/my_extension.rb']) 8 | 9 | write_file('config/teams/bar.yml', <<~CONTENTS) 10 | name: Bar 11 | github: 12 | team: '@org/my-team' 13 | CONTENTS 14 | 15 | write_file('app/services/my_ownable_file.rb') 16 | 17 | write_file('lib/my_extension.rb', <<~RUBY) 18 | class MyExtension 19 | extend T::Sig 20 | include CodeOwnership::Mapper 21 | include CodeOwnership::Validator 22 | 23 | sig do 24 | override.params(file: String). 25 | returns(T.nilable(::CodeTeams::Team)) 26 | end 27 | def map_file_to_owner(file) 28 | CodeTeams.all.last 29 | end 30 | 31 | sig do 32 | override.params(files: T::Array[String]). 33 | returns(T::Hash[String, ::CodeTeams::Team]) 34 | end 35 | def globs_to_owner(files) 36 | Dir.glob('**/*.rb').map{|f| [f, CodeTeams.all.last]}.to_h 37 | end 38 | 39 | sig { override.returns(String) } 40 | def description 41 | 'My special extension' 42 | end 43 | 44 | sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } 45 | def validation_errors(files:, autocorrect: true, stage_changes: true) 46 | ['my validation errors'] 47 | end 48 | 49 | sig { override.void } 50 | def bust_caches! 51 | nil 52 | end 53 | end 54 | RUBY 55 | 56 | expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{codeowners_path}") 57 | end 58 | 59 | after(:all) do 60 | validators_without_extension = Validator.instance_variable_get(:@validators).reject { |v| v == MyExtension } 61 | Validator.instance_variable_set(:@validators, validators_without_extension) 62 | mappers_without_extension = Mapper.instance_variable_get(:@mappers).reject { |v| v == MyExtension } 63 | Mapper.instance_variable_set(:@mappers, mappers_without_extension) 64 | end 65 | 66 | describe 'CodeOwnership.validate!' do 67 | it 'allows third party validations to be injected' do 68 | expect { CodeOwnership.validate! }.to raise_error do |e| 69 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 70 | expect(e.message).to eq <<~EXPECTED.chomp 71 | my validation errors 72 | See https://github.com/rubyatscale/code_ownership#README.md for more details 73 | EXPECTED 74 | end 75 | end 76 | 77 | it 'allows extensions to add to codeowners list' do 78 | expect { CodeOwnership.validate! }.to raise_error(CodeOwnership::InvalidCodeOwnershipConfigurationError) 79 | expect(codeowners_path.read).to eq <<~EXPECTED 80 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 81 | # This file was automatically generated by "bin/codeownership validate". 82 | # 83 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 84 | # teams. This is useful when developers create Pull Requests since the 85 | # code/file owner is notified. Reference GitHub docs for more details: 86 | # https://help.github.com/en/articles/about-code-owners 87 | 88 | 89 | # Team YML ownership 90 | /config/teams/bar.yml @org/my-team 91 | 92 | # My special extension 93 | /app/services/my_ownable_file.rb @org/my-team 94 | /lib/my_extension.rb @org/my-team 95 | EXPECTED 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/owner_assigner_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnerAssigner do 3 | describe '.assign_owners' do 4 | subject(:assign_owners) { described_class.assign_owners(globs_to_owning_team_map) } 5 | 6 | let(:team1) { instance_double(CodeTeams::Team) } 7 | let(:team2) { instance_double(CodeTeams::Team) } 8 | 9 | let(:globs_to_owning_team_map) do 10 | { 11 | 'app/services/[test]/some_other_file.ts' => team1, 12 | 'app/services/withoutbracket/file.ts' => team2, 13 | 'app/models/*.rb' => team2 14 | } 15 | end 16 | 17 | before do 18 | write_file('app/services/[test]/some_other_file.ts', <<~YML) 19 | // @team Bar 20 | YML 21 | 22 | write_file('app/services/withoutbracket/file.ts', <<~YML) 23 | // @team Bar 24 | YML 25 | end 26 | 27 | it 'returns a hash with the same keys and the values that are files' do 28 | expect(assign_owners).to eq( 29 | 'app/services/[test]/some_other_file.ts' => team1, 30 | 'app/services/withoutbracket/file.ts' => team2 31 | ) 32 | end 33 | 34 | context 'when file name includes square brackets' do 35 | let(:globs_to_owning_team_map) do 36 | { 37 | 'app/services/[test]/some_other_[test]_file.ts' => team1 38 | } 39 | end 40 | 41 | before do 42 | write_file('app/services/[test]/some_other_[test]_file.ts', <<~YML) 43 | // @team Bar 44 | YML 45 | 46 | write_file('app/services/t/some_other_e_file.ts', <<~YML) 47 | // @team Bar 48 | YML 49 | end 50 | 51 | it 'matches the glob pattern' do 52 | expect(assign_owners).to eq( 53 | 'app/services/[test]/some_other_[test]_file.ts' => team1, 54 | 'app/services/t/some_other_e_file.ts' => team1 55 | ) 56 | end 57 | end 58 | 59 | context 'when glob pattern also exists' do 60 | before do 61 | write_file('app/services/t/some_other_file.ts', <<~YML) 62 | // @team Bar 63 | YML 64 | end 65 | 66 | it 'also matches the glob pattern' do 67 | expect(assign_owners).to eq( 68 | 'app/services/[test]/some_other_file.ts' => team1, 69 | 'app/services/t/some_other_file.ts' => team1, 70 | 'app/services/withoutbracket/file.ts' => team2 71 | ) 72 | end 73 | end 74 | 75 | context 'when * is used in glob pattern' do 76 | before do 77 | write_file('app/models/some_file.rb', <<~YML) 78 | // @team Bar 79 | YML 80 | 81 | write_file('app/models/nested/some_file.rb', <<~YML) 82 | // @team Bar 83 | YML 84 | end 85 | 86 | it 'also matches the glob pattern' do 87 | expect(assign_owners).to eq( 88 | 'app/services/[test]/some_other_file.ts' => team1, 89 | 'app/services/withoutbracket/file.ts' => team2, 90 | 'app/models/some_file.rb' => team2 91 | ) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/ownership_mappers/directory_ownership_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnershipMappers::DirectoryOwnership do 3 | describe 'CodeOwnershp.for_file' do 4 | before do 5 | write_configuration 6 | 7 | write_file('a/b/.codeowner', <<~CONTENTS) 8 | Bar 9 | CONTENTS 10 | write_file('a/b/c/c_file.jsx') 11 | write_file('a/b/b_file.jsx') 12 | write_file('a/b/[test]/b_file.jsx') 13 | write_file('config/teams/bar.yml', <<~CONTENTS) 14 | name: Bar 15 | CONTENTS 16 | end 17 | 18 | subject { described_class.new } 19 | 20 | before do 21 | subject.bust_caches! 22 | end 23 | 24 | it 'can find the owner of files in team-owned directory' do 25 | expect(subject.map_file_to_owner('a/b/b_file.jsx').name).to eq 'Bar' 26 | end 27 | 28 | it 'can find the owner of files containing [] dirs' do 29 | expect(subject.map_file_to_owner('a/b/[test]/b_file.jsx').name).to eq 'Bar' 30 | end 31 | 32 | it 'can find the owner of files in a sub-directory of a team-owned directory' do 33 | expect(subject.map_file_to_owner('a/b/c/c_file.jsx').name).to eq 'Bar' 34 | end 35 | 36 | it 'returns null when no team is found' do 37 | expect(subject.map_file_to_owner('tmp/tmp/foo.txt')).to be_nil 38 | expect(subject.map_file_to_owner('../tmp/tmp/foo.txt')).to be_nil 39 | expect(subject.map_file_to_owner(Pathname.pwd.join('tmp/tmp/foo.txt').to_s)).to be_nil 40 | end 41 | 42 | it 'looks for codeowner file within directory' do 43 | expect(subject.map_file_to_owner('a/b').name).to eq 'Bar' 44 | expect(subject.map_file_to_owner('a/../a/b').name).to eq 'Bar' 45 | expect(subject.map_file_to_owner(Pathname.pwd.join('a/b').to_s).name).to eq 'Bar' 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/ownership_mappers/file_annotations_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnershipMappers::FileAnnotations do 3 | describe '.for_team' do 4 | before do 5 | write_configuration 6 | write_file('config/teams/bar.yml', <<~CONTENTS) 7 | name: Bar 8 | CONTENTS 9 | 10 | write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) 11 | # @team Bar 12 | CONTENTS 13 | end 14 | 15 | it 'prints out ownership information for the given team' do 16 | expect(CodeOwnership.for_team('Bar')).to eq <<~OWNERSHIP 17 | # Code Ownership Report for `Bar` Team 18 | ## Annotations at the top of file 19 | - packs/my_pack/owned_file.rb 20 | 21 | ## Team-specific owned globs 22 | This team owns nothing in this category. 23 | 24 | ## Owner in .codeowner 25 | This team owns nothing in this category. 26 | 27 | ## Owner metadata key in package.yml 28 | This team owns nothing in this category. 29 | 30 | ## Owner metadata key in package.json 31 | This team owns nothing in this category. 32 | 33 | ## Team YML ownership 34 | - config/teams/bar.yml 35 | OWNERSHIP 36 | end 37 | end 38 | 39 | describe '.for_file' do 40 | context 'path is a directory' do 41 | it 'returns nil' do 42 | write_configuration 43 | write_file('config/teams/bar.yml', <<~CONTENTS) 44 | name: Bar 45 | CONTENTS 46 | 47 | expect(CodeOwnership.for_file('config/teams')).to be_nil 48 | end 49 | end 50 | 51 | context 'path does not exist' do 52 | it 'returns nil' do 53 | write_configuration 54 | write_file('config/teams/bar.yml', <<~CONTENTS) 55 | name: Bar 56 | CONTENTS 57 | 58 | expect(CodeOwnership.for_file('config/teams/foo.yml')).to be_nil 59 | end 60 | end 61 | 62 | context 'ruby owned file' do 63 | before do 64 | write_configuration 65 | write_file('config/teams/bar.yml', <<~CONTENTS) 66 | name: Bar 67 | CONTENTS 68 | 69 | write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) 70 | # @team Bar 71 | CONTENTS 72 | end 73 | 74 | it 'can find the owner of a ruby file with file annotations' do 75 | expect(CodeOwnership.for_file('packs/my_pack/owned_file.rb').name).to eq 'Bar' 76 | end 77 | end 78 | 79 | context 'javascript owned file' do 80 | before do 81 | write_configuration 82 | write_file('config/teams/bar.yml', <<~CONTENTS) 83 | name: Bar 84 | CONTENTS 85 | 86 | write_file('frontend/javascripts/packages/my_package/owned_file.jsx', <<~CONTENTS) 87 | // @team Bar 88 | CONTENTS 89 | end 90 | 91 | it 'can find the owner of a javascript file with file annotations' do 92 | expect(CodeOwnership.for_file('frontend/javascripts/packages/my_package/owned_file.jsx').name).to eq 'Bar' 93 | end 94 | end 95 | 96 | context 'javascript owned file with brackets' do 97 | before do 98 | write_configuration 99 | write_file('config/teams/bar.yml', <<~CONTENTS) 100 | name: Bar 101 | CONTENTS 102 | 103 | write_file('frontend/javascripts/packages/my_package/[formID]/owned_file.jsx', <<~CONTENTS) 104 | // @team Bar 105 | CONTENTS 106 | end 107 | 108 | it 'can find the owner of a javascript file with file annotations' do 109 | expect(CodeOwnership.for_file('frontend/javascripts/packages/my_package/[formID]/owned_file.jsx').name).to eq 'Bar' 110 | end 111 | end 112 | 113 | context 'haml owned file' do 114 | before do 115 | write_configuration 116 | write_file('config/teams/bar.yml', <<~CONTENTS) 117 | name: Bar 118 | CONTENTS 119 | 120 | write_file('packs/my_pack/owned_file.html.haml', <<~CONTENTS) 121 | -# @team Bar 122 | CONTENTS 123 | end 124 | 125 | it 'can find the owner of a haml file with file annotations' do 126 | expect(CodeOwnership.for_file('packs/my_pack/owned_file.html.haml').name).to eq 'Bar' 127 | end 128 | end 129 | end 130 | 131 | 132 | 133 | describe '.remove_file_annotation!' do 134 | subject(:remove_file_annotation) do 135 | CodeOwnership.remove_file_annotation!(filename) 136 | # Getting the owner gets stored in the cache, so after we remove the file annotation we want to bust the cache 137 | CodeOwnership.bust_caches! 138 | end 139 | 140 | before do 141 | write_file('config/teams/foo.yml', <<~CONTENTS) 142 | name: Foo 143 | CONTENTS 144 | write_configuration 145 | end 146 | 147 | context 'ruby file has no annotation' do 148 | let(:filename) { 'app/my_file.rb' } 149 | 150 | before do 151 | write_file(filename, <<~CONTENTS) 152 | # Empty file 153 | CONTENTS 154 | end 155 | 156 | it 'has no effect' do 157 | expect(File.read(filename)).to eq "# Empty file\n" 158 | 159 | remove_file_annotation 160 | 161 | expect(File.read(filename)).to eq "# Empty file\n" 162 | end 163 | end 164 | 165 | context 'ruby file has annotation' do 166 | let(:filename) { 'app/my_file.rb' } 167 | 168 | before do 169 | write_file(filename, <<~CONTENTS) 170 | # @team Foo 171 | 172 | # Some content 173 | CONTENTS 174 | 175 | write_file('package.yml', <<~CONTENTS) 176 | enforce_dependency: true 177 | enforce_privacy: true 178 | CONTENTS 179 | end 180 | 181 | it 'removes the annotation' do 182 | current_ownership = CodeOwnership.for_file(filename) 183 | expect(current_ownership&.name).to eq 'Foo' 184 | expect(File.read(filename)).to eq <<~RUBY 185 | # @team Foo 186 | 187 | # Some content 188 | RUBY 189 | 190 | remove_file_annotation 191 | 192 | new_ownership = CodeOwnership.for_file(filename) 193 | expect(new_ownership).to eq nil 194 | expected_output = <<~RUBY 195 | # Some content 196 | RUBY 197 | 198 | expect(File.read(filename)).to eq expected_output 199 | end 200 | end 201 | 202 | context 'javascript file has annotation' do 203 | let(:filename) { 'app/my_file.jsx' } 204 | 205 | before do 206 | write_file(filename, <<~CONTENTS) 207 | // @team Foo 208 | 209 | // Some content 210 | CONTENTS 211 | 212 | write_file('package.yml', <<~CONTENTS) 213 | enforce_dependency: true 214 | enforce_privacy: true 215 | CONTENTS 216 | end 217 | 218 | it 'removes the annotation' do 219 | current_ownership = CodeOwnership.for_file(filename) 220 | expect(current_ownership&.name).to eq 'Foo' 221 | expect(File.read(filename)).to eq <<~JAVASCRIPT 222 | // @team Foo 223 | 224 | // Some content 225 | JAVASCRIPT 226 | 227 | remove_file_annotation 228 | 229 | new_ownership = CodeOwnership.for_file(filename) 230 | expect(new_ownership).to eq nil 231 | expected_output = <<~JAVASCRIPT 232 | // Some content 233 | JAVASCRIPT 234 | 235 | expect(File.read(filename)).to eq expected_output 236 | end 237 | end 238 | 239 | context 'haml has annotation' do 240 | let(:filename) { 'app.my_file.html.haml' } 241 | 242 | before do 243 | write_file(filename, <<~CONTENTS) 244 | -# @team Foo 245 | 246 | -# Some content 247 | CONTENTS 248 | 249 | write_file('package.yml', <<~CONTENTS) 250 | enforce_dependency: true 251 | enforce_privacy: true 252 | CONTENTS 253 | end 254 | 255 | it 'removes the annotation' do 256 | current_ownership = CodeOwnership.for_file(filename) 257 | expect(current_ownership&.name).to eq 'Foo' 258 | expect(File.read(filename)).to eq <<~HAML 259 | -# @team Foo 260 | 261 | -# Some content 262 | HAML 263 | 264 | remove_file_annotation 265 | 266 | new_ownership = CodeOwnership.for_file(filename) 267 | expect(new_ownership).to eq nil 268 | expected_output = <<~HAML 269 | -# Some content 270 | HAML 271 | 272 | expect(File.read(filename)).to eq expected_output 273 | end 274 | end 275 | 276 | context 'file has new lines after the annotation' do 277 | let(:filename) { 'app/my_file.rb' } 278 | 279 | before do 280 | write_file(filename, <<~CONTENTS) 281 | # @team Foo 282 | 283 | 284 | # Some content 285 | 286 | 287 | # Some other content 288 | CONTENTS 289 | end 290 | 291 | it 'removes the annotation and the leading new lines' do 292 | expect(File.read(filename)).to eq <<~RUBY 293 | # @team Foo 294 | 295 | 296 | # Some content 297 | 298 | 299 | # Some other content 300 | RUBY 301 | 302 | remove_file_annotation 303 | 304 | expected_output = <<~RUBY 305 | # Some content 306 | 307 | 308 | # Some other content 309 | RUBY 310 | 311 | expect(File.read(filename)).to eq expected_output 312 | end 313 | end 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/ownership_mappers/js_package_ownership_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnershipMappers::JsPackageOwnership do 3 | describe 'CodeOwnership.validate!' do 4 | context 'application has invalid JSON in package' do 5 | before do 6 | write_configuration 7 | 8 | write_file('frontend/javascripts/my_package/package.json', <<~CONTENTS) 9 | { syntax error!!! 10 | "metadata": { 11 | "owner": "Foo" 12 | } 13 | } 14 | CONTENTS 15 | end 16 | 17 | it 'lets the user know the their package JSON is invalid' do 18 | expect { CodeOwnership.validate! }.to raise_error do |e| 19 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 20 | expect(e.message).to match(/JSON::ParserError/) 21 | expect(e.message).to include 'frontend/javascripts/my_package/package.json has invalid JSON, so code ownership cannot be determined.' 22 | expect(e.message).to include 'Please either make the JSON in that file valid or specify `js_package_paths` in config/code_ownership.yml.' 23 | end 24 | end 25 | end 26 | end 27 | 28 | describe 'CodeOwnershp.for_file' do 29 | before do 30 | write_configuration 31 | 32 | write_file('frontend/javascripts/packages/my_other_package/package.json', <<~CONTENTS) 33 | { 34 | "name": "@gusto/my_package", 35 | "metadata": { 36 | "owner": "Bar" 37 | } 38 | } 39 | CONTENTS 40 | write_file('frontend/javascripts/packages/my_other_package/my_file.jsx') 41 | write_file('frontend/javascripts/packages/different_package/test/my_file.ts', <<~CONTENTS) 42 | // @team Bar 43 | CONTENTS 44 | write_file('frontend/javascripts/packages/different_package/[test]/my_file.ts', <<~CONTENTS) 45 | // @team Bar 46 | CONTENTS 47 | write_file('config/teams/bar.yml', <<~CONTENTS) 48 | name: Bar 49 | CONTENTS 50 | end 51 | 52 | it 'can find the owner of files in team-owned javascript packages' do 53 | expect(CodeOwnership.for_file('frontend/javascripts/packages/my_other_package/my_file.jsx').name).to eq 'Bar' 54 | expect(CodeOwnership.for_file('frontend/javascripts/packages/different_package/[test]/my_file.ts').name).to eq 'Bar' 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/ownership_mappers/package_ownership_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnershipMappers::PackageOwnership do 3 | before do 4 | write_configuration 5 | write_file('config/teams/bar.yml', <<~CONTENTS) 6 | name: Bar 7 | CONTENTS 8 | 9 | write_file('packs/my_other_package/package.yml', <<~CONTENTS) 10 | enforce_dependency: true 11 | enforce_privacy: true 12 | owner: Bar 13 | CONTENTS 14 | 15 | write_file('packs/my_other_package/my_file.rb') 16 | 17 | write_file('package.yml', <<~CONTENTS) 18 | enforce_dependency: true 19 | enforce_privacy: true 20 | CONTENTS 21 | end 22 | 23 | describe 'CodeOwnership.for_team' do 24 | it 'prints out ownership information for the given team' do 25 | expect(CodeOwnership.for_team('Bar')).to eq <<~OWNERSHIP 26 | # Code Ownership Report for `Bar` Team 27 | ## Annotations at the top of file 28 | This team owns nothing in this category. 29 | 30 | ## Team-specific owned globs 31 | This team owns nothing in this category. 32 | 33 | ## Owner in .codeowner 34 | This team owns nothing in this category. 35 | 36 | ## Owner metadata key in package.yml 37 | - packs/my_other_package/**/** 38 | 39 | ## Owner metadata key in package.json 40 | This team owns nothing in this category. 41 | 42 | ## Team YML ownership 43 | - config/teams/bar.yml 44 | OWNERSHIP 45 | end 46 | end 47 | 48 | describe 'CodeOwnership.for_file' do 49 | it 'can find the owner of files in team-owned pack' do 50 | expect(CodeOwnership.for_file('packs/my_other_package/my_file.rb').name).to eq 'Bar' 51 | end 52 | end 53 | 54 | describe 'CodeOwnership.for_package' do 55 | it 'returns the right team' do 56 | team = CodeOwnership.for_package(Packs.find('packs/my_other_package')) 57 | expect(team.name).to eq 'Bar' 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/ownership_mappers/team_globs_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnershipMappers::TeamGlobs do 3 | before { write_configuration } 4 | 5 | describe 'CodeOwnership.for_file' do 6 | before do 7 | write_file('config/teams/bar.yml', <<~CONTENTS) 8 | name: Bar 9 | owned_globs: 10 | - app/services/bar_stuff/**/** 11 | - frontend/javascripts/bar_stuff/**/** 12 | - '**/team_thing/**/*' 13 | unowned_globs: 14 | - shared/**/team_thing/**/* 15 | CONTENTS 16 | 17 | write_file('app/services/bar_stuff/thing.rb') 18 | write_file('app/services/bar_stuff/[test]/thing.rb') 19 | write_file('frontend/javascripts/bar_stuff/thing.jsx') 20 | write_file('app/services/team_thing/thing.rb') 21 | write_file('shared/config/team_thing/something.rb') 22 | end 23 | 24 | it 'can find the owner of ruby files in owned_globs' do 25 | expect(CodeOwnership.for_file('app/services/bar_stuff/thing.rb').name).to eq 'Bar' 26 | expect(CodeOwnership.for_file('app/services/bar_stuff/[test]/thing.rb').name).to eq 'Bar' 27 | expect(CodeOwnership.for_file('app/services/team_thing/thing.rb').name).to eq 'Bar' 28 | end 29 | 30 | it 'does not find the owner of ruby files in unowned_globs' do 31 | expect(CodeOwnership.for_file('shared/config/team_thing/something.rb')).to be_nil 32 | end 33 | 34 | it 'can find the owner of javascript files in owned_globs' do 35 | expect(CodeOwnership.for_file('frontend/javascripts/bar_stuff/thing.jsx').name).to eq 'Bar' 36 | end 37 | end 38 | 39 | describe 'CodeOwnership.validate!' do 40 | context 'has unowned globs' do 41 | before do 42 | write_file('config/teams/bar.yml', <<~CONTENTS) 43 | name: Bar 44 | owned_globs: 45 | - app/services/bar_stuff/**/** 46 | - frontend/javascripts/bar_stuff/**/** 47 | - '**/team_thing/**/*' 48 | unowned_globs: 49 | - shared/**/team_thing/**/* 50 | CONTENTS 51 | end 52 | 53 | it 'considers the combination of owned_globs and unowned_globs' do 54 | expect { CodeOwnership.validate! }.to_not raise_error 55 | end 56 | end 57 | 58 | context 'two teams own the same exact glob' do 59 | before do 60 | write_configuration 61 | 62 | write_file('packs/my_pack/owned_file.rb') 63 | write_file('frontend/javascripts/blah/my_file.rb') 64 | write_file('frontend/javascripts/blah/subdir/my_file.rb') 65 | 66 | write_file('config/teams/bar.yml', <<~CONTENTS) 67 | name: Bar 68 | owned_globs: 69 | - packs/**/** 70 | - frontend/javascripts/blah/subdir/my_file.rb 71 | CONTENTS 72 | 73 | write_file('config/teams/foo.yml', <<~CONTENTS) 74 | name: Foo 75 | owned_globs: 76 | - packs/**/** 77 | - frontend/javascripts/blah/**/** 78 | CONTENTS 79 | end 80 | 81 | it 'lets the user know that `owned_globs` can not overlap' do 82 | expect { CodeOwnership.validate! }.to raise_error do |e| 83 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 84 | expect(e.message).to eq <<~EXPECTED.chomp 85 | `owned_globs` cannot overlap between teams. The following globs overlap: 86 | 87 | - `packs/**/**` (from `config/teams/bar.yml`), `packs/**/**` (from `config/teams/foo.yml`) 88 | - `frontend/javascripts/blah/subdir/my_file.rb` (from `config/teams/bar.yml`), `frontend/javascripts/blah/**/**` (from `config/teams/foo.yml`) 89 | 90 | See https://github.com/rubyatscale/code_ownership#README.md for more details 91 | EXPECTED 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/ownership_mappers/team_yml_ownership_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::OwnershipMappers::TeamYmlOwnership do 3 | before do 4 | write_configuration 5 | write_file('config/teams/bar.yml', <<~CONTENTS) 6 | name: Bar 7 | CONTENTS 8 | end 9 | 10 | describe 'CodeOwnership.for_team' do 11 | it 'prints out ownership information for the given team' do 12 | expect(CodeOwnership.for_team('Bar')).to eq <<~OWNERSHIP 13 | # Code Ownership Report for `Bar` Team 14 | ## Annotations at the top of file 15 | This team owns nothing in this category. 16 | 17 | ## Team-specific owned globs 18 | This team owns nothing in this category. 19 | 20 | ## Owner in .codeowner 21 | This team owns nothing in this category. 22 | 23 | ## Owner metadata key in package.yml 24 | This team owns nothing in this category. 25 | 26 | ## Owner metadata key in package.json 27 | This team owns nothing in this category. 28 | 29 | ## Team YML ownership 30 | - config/teams/bar.yml 31 | OWNERSHIP 32 | end 33 | end 34 | 35 | describe 'CodeOwnership.for_file' do 36 | it 'maps a team YML to be owned by the team itself' do 37 | expect(CodeOwnership.for_file('config/teams/bar.yml').name).to eq 'Bar' 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/validations/files_have_owners_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::Validations::FilesHaveOwners do 3 | describe 'CodeOwnership.validate!' do 4 | let(:codeowners_validation) { Private::Validations::GithubCodeownersUpToDate } 5 | 6 | context 'input files are not part of configured owned_globs' do 7 | before do 8 | write_file('Gemfile', '') 9 | 10 | write_configuration 11 | end 12 | 13 | it 'does not raise an error' do 14 | expect { CodeOwnership.validate!(files: ['Gemfile']) }.to_not raise_error 15 | end 16 | end 17 | 18 | context 'a file in owned_globs does not have an owner' do 19 | before do 20 | write_file('app/missing_ownership.rb', '') 21 | 22 | write_file('app/some_other_file.rb', <<~CONTENTS) 23 | # @team Bar 24 | CONTENTS 25 | 26 | write_file('config/teams/bar.yml', <<~CONTENTS) 27 | name: Bar 28 | CONTENTS 29 | end 30 | 31 | context 'the file is not in unowned_globs' do 32 | before do 33 | write_configuration 34 | end 35 | 36 | it 'lets the user know the file must have ownership' do 37 | expect { CodeOwnership.validate! }.to raise_error do |e| 38 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 39 | expect(e.message).to eq <<~EXPECTED.chomp 40 | Some files are missing ownership: 41 | 42 | - app/missing_ownership.rb 43 | 44 | See https://github.com/rubyatscale/code_ownership#README.md for more details 45 | EXPECTED 46 | end 47 | end 48 | 49 | context 'the input files do not include the file missing ownership' do 50 | it 'ignores the file missing ownership' do 51 | expect { CodeOwnership.validate!(files: ['app/some_other_file.rb']) }.to_not raise_error 52 | end 53 | end 54 | end 55 | 56 | context 'that file is in unowned_globs' do 57 | before do 58 | write_configuration('unowned_globs' => ['app/missing_ownership.rb', 'config/code_ownership.yml']) 59 | end 60 | 61 | it 'does not raise an error' do 62 | expect { CodeOwnership.validate! }.to_not raise_error 63 | end 64 | end 65 | end 66 | 67 | context 'many files in owned_globs do not have an owner' do 68 | before do 69 | write_configuration 70 | 71 | 500.times do |i| 72 | write_file("app/missing_ownership#{i}.rb", '') 73 | end 74 | end 75 | 76 | it 'lets the user know the file must have ownership' do 77 | expect { CodeOwnership.validate! }.to raise_error do |e| 78 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 79 | expect(e.message).to include 'Some files are missing ownership:' 80 | 500.times do |i| 81 | expect(e.message).to include "- app/missing_ownership#{i}.rb" 82 | end 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/validations/files_have_unique_owners_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::Validations::FilesHaveUniqueOwners do 3 | describe 'CodeOwnership.validate!' do 4 | context 'a file in owned_globs has ownership defined in multiple ways' do 5 | before do 6 | write_configuration 7 | 8 | write_file('app/services/some_other_file.rb', <<~YML) 9 | # @team Bar 10 | YML 11 | 12 | write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) 13 | # @team Bar 14 | CONTENTS 15 | 16 | write_file('frontend/javascripts/packages/my_package/owned_file.jsx', <<~CONTENTS) 17 | // @team Bar 18 | CONTENTS 19 | 20 | write_file('frontend/javascripts/packages/my_package/.codeowner', <<~CONTENTS) 21 | Bar 22 | CONTENTS 23 | 24 | write_file('frontend/javascripts/packages/my_package/package.json', <<~CONTENTS) 25 | { 26 | "name": "@gusto/my_package", 27 | "metadata": { 28 | "owner": "Bar" 29 | } 30 | } 31 | CONTENTS 32 | 33 | write_file('config/teams/bar.yml', <<~CONTENTS) 34 | name: Bar 35 | owned_globs: 36 | - packs/**/** 37 | - frontend/javascripts/packages/**/** 38 | CONTENTS 39 | 40 | write_file('packs/my_pack/package.yml', <<~CONTENTS) 41 | enforce_dependency: true 42 | enforce_privacy: true 43 | owner: Bar 44 | CONTENTS 45 | 46 | write_file('package.yml', <<~CONTENTS) 47 | enforce_dependency: true 48 | enforce_privacy: true 49 | CONTENTS 50 | end 51 | 52 | it 'lets the user know that each file can only have ownership defined in one way' do 53 | expect(CodeOwnership.for_file('app/missing_ownership.rb')).to eq nil 54 | expect { CodeOwnership.validate! }.to raise_error do |e| 55 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 56 | expect(e.message).to eq <<~EXPECTED.chomp 57 | Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways. 58 | 59 | - frontend/javascripts/packages/my_package/owned_file.jsx (Annotations at the top of file, Team-specific owned globs, Owner in .codeowner, Owner metadata key in package.json) 60 | - frontend/javascripts/packages/my_package/package.json (Team-specific owned globs, Owner in .codeowner, Owner metadata key in package.json) 61 | - packs/my_pack/owned_file.rb (Annotations at the top of file, Team-specific owned globs, Owner metadata key in package.yml) 62 | - packs/my_pack/package.yml (Team-specific owned globs, Owner metadata key in package.yml) 63 | 64 | See https://github.com/rubyatscale/code_ownership#README.md for more details 65 | EXPECTED 66 | end 67 | end 68 | 69 | it "ignores the file with multiple ownership if it's not in the files param" do 70 | expect { CodeOwnership.validate!(files: ['app/services/some_other_file.rb']) }.to_not raise_error 71 | end 72 | end 73 | 74 | context 'with mutliple directory ownership files' do 75 | before do 76 | write_configuration 77 | 78 | write_file('config/teams/bar.yml', <<~CONTENTS) 79 | name: Bar 80 | CONTENTS 81 | 82 | write_file('config/teams/foo.yml', <<~CONTENTS) 83 | name: Foo 84 | CONTENTS 85 | 86 | write_file('app/services/exciting/some_other_file.rb', <<~YML) 87 | class Exciting::SomeOtherFile; end 88 | YML 89 | 90 | write_file('app/services/exciting/.codeowner', <<~YML) 91 | Bar 92 | YML 93 | 94 | write_file('app/services/.codeowner', <<~YML) 95 | Foo 96 | YML 97 | end 98 | 99 | it 'allows multiple .codeowner ancestor files' do 100 | expect(CodeOwnership.for_file('app/services/exciting/some_other_file.rb').name).to eq 'Bar' 101 | expect { CodeOwnership.validate! }.to_not raise_error 102 | end 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/lib/code_ownership/private/validations/github_codeowners_up_to_date_spec.rb: -------------------------------------------------------------------------------- 1 | module CodeOwnership 2 | RSpec.describe Private::Validations::GithubCodeownersUpToDate do 3 | describe 'CodeOwnership.validate!' do 4 | let(:codeowners_validation) { Private::Validations::GithubCodeownersUpToDate } 5 | 6 | context 'run with autocorrect' do 7 | before do 8 | write_configuration 9 | end 10 | 11 | context 'in an empty application' do 12 | it 'automatically regenerates the codeowners file' do 13 | expect(codeowners_path).to_not exist 14 | expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{codeowners_path}") 15 | expect { CodeOwnership.validate! }.to_not raise_error 16 | expect(codeowners_path.read).to eq <<~EXPECTED 17 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 18 | # This file was automatically generated by "bin/codeownership validate". 19 | # 20 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 21 | # teams. This is useful when developers create Pull Requests since the 22 | # code/file owner is notified. Reference GitHub docs for more details: 23 | # https://help.github.com/en/articles/about-code-owners 24 | 25 | EXPECTED 26 | end 27 | end 28 | 29 | context 'in an non-empty application' do 30 | before { create_non_empty_application } 31 | 32 | it 'automatically regenerates the codeowners file' do 33 | expect(codeowners_path).to_not exist 34 | expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{codeowners_path}") 35 | expect { CodeOwnership.validate! }.to_not raise_error 36 | expect(codeowners_path.read).to eq <<~EXPECTED 37 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 38 | # This file was automatically generated by "bin/codeownership validate". 39 | # 40 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 41 | # teams. This is useful when developers create Pull Requests since the 42 | # code/file owner is notified. Reference GitHub docs for more details: 43 | # https://help.github.com/en/articles/about-code-owners 44 | 45 | 46 | # Annotations at the top of file 47 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 48 | /packs/my_pack/owned_file.rb @MyOrg/bar-team 49 | 50 | # Team-specific owned globs 51 | /app/services/bar_stuff/** @MyOrg/bar-team 52 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 53 | 54 | # Owner in .codeowner 55 | /directory/owner/**/** @MyOrg/bar-team 56 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 57 | 58 | # Owner metadata key in package.yml 59 | /packs/my_other_package/**/** @MyOrg/bar-team 60 | 61 | # Owner metadata key in package.json 62 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 63 | 64 | # Team YML ownership 65 | /config/teams/bar.yml @MyOrg/bar-team 66 | /config/teams/foo.yml @MyOrg/foo-team 67 | EXPECTED 68 | end 69 | 70 | context 'the user has passed in specific input files into the validate method' do 71 | it 'still automatically regenerates the codeowners file, since we look at all files when regenerating CODEOWNERS' do 72 | expect(codeowners_path).to_not exist 73 | expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{codeowners_path}") 74 | expect { CodeOwnership.validate! }.to_not raise_error 75 | expect(codeowners_path.read).to eq <<~EXPECTED 76 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 77 | # This file was automatically generated by "bin/codeownership validate". 78 | # 79 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 80 | # teams. This is useful when developers create Pull Requests since the 81 | # code/file owner is notified. Reference GitHub docs for more details: 82 | # https://help.github.com/en/articles/about-code-owners 83 | 84 | 85 | # Annotations at the top of file 86 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 87 | /packs/my_pack/owned_file.rb @MyOrg/bar-team 88 | 89 | # Team-specific owned globs 90 | /app/services/bar_stuff/** @MyOrg/bar-team 91 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 92 | 93 | # Owner in .codeowner 94 | /directory/owner/**/** @MyOrg/bar-team 95 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 96 | 97 | # Owner metadata key in package.yml 98 | /packs/my_other_package/**/** @MyOrg/bar-team 99 | 100 | # Owner metadata key in package.json 101 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 102 | 103 | # Team YML ownership 104 | /config/teams/bar.yml @MyOrg/bar-team 105 | /config/teams/foo.yml @MyOrg/foo-team 106 | EXPECTED 107 | end 108 | end 109 | 110 | context 'team does not have a github team listed' do 111 | before do 112 | write_file('config/teams/bar.yml', <<~CONTENTS) 113 | name: Bar 114 | owned_globs: 115 | - app/services/bar_stuff/** 116 | - frontend/javascripts/bar_stuff/** 117 | CONTENTS 118 | end 119 | 120 | it 'does not include the team in the output' do 121 | expect(codeowners_path).to_not exist 122 | expect { CodeOwnership.validate! }.to_not raise_error 123 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 124 | expect(codeowners_path.read).to eq <<~EXPECTED 125 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 126 | # This file was automatically generated by "bin/codeownership validate". 127 | # 128 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 129 | # teams. This is useful when developers create Pull Requests since the 130 | # code/file owner is notified. Reference GitHub docs for more details: 131 | # https://help.github.com/en/articles/about-code-owners 132 | 133 | 134 | # Owner in .codeowner 135 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 136 | 137 | # Team YML ownership 138 | /config/teams/foo.yml @MyOrg/foo-team 139 | EXPECTED 140 | end 141 | end 142 | 143 | context 'team has chosen to not be added to CODEOWNERS' do 144 | before do 145 | write_file('config/teams/bar.yml', <<~CONTENTS) 146 | name: Bar 147 | github: 148 | team: '@MyOrg/bar-team' 149 | do_not_add_to_codeowners_file: true 150 | owned_globs: 151 | - app/services/bar_stuff/** 152 | - frontend/javascripts/bar_stuff/** 153 | CONTENTS 154 | end 155 | 156 | it 'does not include the team in the output' do 157 | expect(codeowners_path).to_not exist 158 | expect { CodeOwnership.validate! }.to_not raise_error 159 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 160 | expect(codeowners_path.read).to eq <<~EXPECTED 161 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 162 | # This file was automatically generated by "bin/codeownership validate". 163 | # 164 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 165 | # teams. This is useful when developers create Pull Requests since the 166 | # code/file owner is notified. Reference GitHub docs for more details: 167 | # https://help.github.com/en/articles/about-code-owners 168 | 169 | 170 | # Annotations at the top of file 171 | # /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 172 | # /packs/my_pack/owned_file.rb @MyOrg/bar-team 173 | 174 | # Team-specific owned globs 175 | # /app/services/bar_stuff/** @MyOrg/bar-team 176 | # /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 177 | 178 | # Owner in .codeowner 179 | # /directory/owner/**/** @MyOrg/bar-team 180 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 181 | 182 | # Owner metadata key in package.yml 183 | # /packs/my_other_package/**/** @MyOrg/bar-team 184 | 185 | # Owner metadata key in package.json 186 | # /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 187 | 188 | # Team YML ownership 189 | # /config/teams/bar.yml @MyOrg/bar-team 190 | /config/teams/foo.yml @MyOrg/foo-team 191 | EXPECTED 192 | end 193 | end 194 | end 195 | 196 | context 'run without staging changes' do 197 | before do 198 | write_configuration 199 | end 200 | 201 | it 'does not stage the changes to the codeowners file' do 202 | expect(codeowners_path).to_not exist 203 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 204 | expect { CodeOwnership.validate!(stage_changes: false) }.to_not raise_error 205 | expect(codeowners_path.read).to eq <<~EXPECTED 206 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 207 | # This file was automatically generated by "bin/codeownership validate". 208 | # 209 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 210 | # teams. This is useful when developers create Pull Requests since the 211 | # code/file owner is notified. Reference GitHub docs for more details: 212 | # https://help.github.com/en/articles/about-code-owners 213 | 214 | EXPECTED 215 | end 216 | end 217 | end 218 | 219 | context 'run without autocorrect' do 220 | before do 221 | write_configuration 222 | end 223 | 224 | context 'in an empty application' do 225 | it 'automatically regenerates the codeowners file' do 226 | expect(codeowners_path).to_not exist 227 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 228 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 229 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 230 | expect(e.message).to eq <<~EXPECTED.chomp 231 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 232 | 233 | See https://github.com/rubyatscale/code_ownership#README.md for more details 234 | EXPECTED 235 | end 236 | expect(codeowners_path).to_not exist 237 | end 238 | end 239 | 240 | context 'in an non-empty application' do 241 | before { create_non_empty_application } 242 | 243 | it 'automatically regenerates the codeowners file' do 244 | expect(codeowners_path).to_not exist 245 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 246 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 247 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 248 | expect(e.message).to eq <<~EXPECTED.chomp 249 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 250 | 251 | See https://github.com/rubyatscale/code_ownership#README.md for more details 252 | EXPECTED 253 | end 254 | expect(codeowners_path).to_not exist 255 | end 256 | 257 | context 'team does not have a github team listed' do 258 | before do 259 | write_file('config/teams/bar.yml', <<~CONTENTS) 260 | name: Bar 261 | owned_globs: 262 | - app/services/bar_stuff/** 263 | - frontend/javascripts/bar_stuff/** 264 | CONTENTS 265 | end 266 | 267 | it 'does not include the team in the output' do 268 | expect(codeowners_path).to_not exist 269 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 270 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 271 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 272 | expect(e.message).to eq <<~EXPECTED.chomp 273 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 274 | 275 | See https://github.com/rubyatscale/code_ownership#README.md for more details 276 | EXPECTED 277 | end 278 | expect(codeowners_path).to_not exist 279 | end 280 | end 281 | 282 | context 'team has chosen to not be added to CODEOWNERS' do 283 | before do 284 | write_file('config/teams/bar.yml', <<~CONTENTS) 285 | name: Bar 286 | github: 287 | team: '@MyOrg/bar-team' 288 | do_not_add_to_codeowners_file: true 289 | owned_globs: 290 | - app/services/bar_stuff/** 291 | - frontend/javascripts/bar_stuff/** 292 | CONTENTS 293 | end 294 | 295 | it 'does not include the team in the output' do 296 | expect(codeowners_path).to_not exist 297 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 298 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 299 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 300 | expect(e.message).to eq <<~EXPECTED.chomp 301 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 302 | 303 | See https://github.com/rubyatscale/code_ownership#README.md for more details 304 | EXPECTED 305 | end 306 | expect(codeowners_path).to_not exist 307 | end 308 | end 309 | end 310 | 311 | context 'in an application with a CODEOWNERS file that is missing lines and has extra lines' do 312 | before { create_non_empty_application } 313 | 314 | it 'prints out the diff' do 315 | FileUtils.mkdir('.github') 316 | codeowners_path.write <<~CODEOWNERS 317 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 318 | # This file was automatically generated by "bin/codeownership validate". 319 | # 320 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 321 | # teams. This is useful when developers create Pull Requests since the 322 | # code/file owner is notified. Reference GitHub docs for more details: 323 | # https://help.github.com/en/articles/about-code-owners 324 | 325 | 326 | # Annotations at the top of file 327 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 328 | /frontend/some/extra/line/that/should/not/exist @MyOrg/bar-team 329 | 330 | # Team-specific owned globs 331 | /app/services/bar_stuff/** @MyOrg/bar-team 332 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 333 | 334 | # Some extra comment that should not be here 335 | 336 | # Owner metadata key in package.json 337 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 338 | 339 | # Team YML ownership 340 | /config/teams/bar.yml @MyOrg/bar-team 341 | CODEOWNERS 342 | 343 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 344 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 345 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 346 | expect(e.message).to eq <<~EXPECTED.chomp 347 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 348 | 349 | CODEOWNERS should contain the following lines, but does not: 350 | - "/packs/my_pack/owned_file.rb @MyOrg/bar-team" 351 | - "/config/teams/foo.yml @MyOrg/foo-team" 352 | - "# Owner in .codeowner" 353 | - "/directory/owner/**/** @MyOrg/bar-team" 354 | - "/directory/owner/(my_folder)/**/** @MyOrg/foo-team" 355 | - "# Owner metadata key in package.yml" 356 | - "/packs/my_other_package/**/** @MyOrg/bar-team" 357 | 358 | CODEOWNERS should not contain the following lines, but it does: 359 | - "/frontend/some/extra/line/that/should/not/exist @MyOrg/bar-team" 360 | - "# Some extra comment that should not be here" 361 | 362 | See https://github.com/rubyatscale/code_ownership#README.md for more details 363 | EXPECTED 364 | end 365 | end 366 | end 367 | 368 | context 'in an application with a CODEOWNERS file that has extra lines' do 369 | before { create_non_empty_application } 370 | 371 | it 'prints out the diff' do 372 | FileUtils.mkdir('.github') 373 | codeowners_path.write <<~CODEOWNERS 374 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 375 | # This file was automatically generated by "bin/codeownership validate". 376 | # 377 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 378 | # teams. This is useful when developers create Pull Requests since the 379 | # code/file owner is notified. Reference GitHub docs for more details: 380 | # https://help.github.com/en/articles/about-code-owners 381 | 382 | 383 | # Annotations at the top of file 384 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 385 | /packs/my_pack/owned_file.rb @MyOrg/bar-team 386 | /frontend/some/extra/line/that/should/not/exist @MyOrg/bar-team 387 | 388 | # Team-specific owned globs 389 | /app/services/bar_stuff/** @MyOrg/bar-team 390 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 391 | 392 | # Owner in .codeowner 393 | /directory/owner/**/** @MyOrg/bar-team 394 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 395 | 396 | # Owner metadata key in package.yml 397 | /packs/my_other_package/**/** @MyOrg/bar-team 398 | 399 | # Some extra comment that should not be here 400 | 401 | # Owner metadata key in package.json 402 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 403 | 404 | # Team YML ownership 405 | /config/teams/bar.yml @MyOrg/bar-team 406 | /config/teams/foo.yml @MyOrg/foo-team 407 | CODEOWNERS 408 | 409 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 410 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 411 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 412 | expect(e.message).to eq <<~EXPECTED.chomp 413 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 414 | 415 | CODEOWNERS should not contain the following lines, but it does: 416 | - "/frontend/some/extra/line/that/should/not/exist @MyOrg/bar-team" 417 | - "# Some extra comment that should not be here" 418 | 419 | See https://github.com/rubyatscale/code_ownership#README.md for more details 420 | EXPECTED 421 | end 422 | end 423 | end 424 | 425 | context 'in an application with a CODEOWNERS file that has missing lines' do 426 | before { create_non_empty_application } 427 | 428 | it 'prints out the diff' do 429 | FileUtils.mkdir('.github') 430 | codeowners_path.write <<~CODEOWNERS 431 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 432 | # This file was automatically generated by "bin/codeownership validate". 433 | # 434 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 435 | # teams. This is useful when developers create Pull Requests since the 436 | # code/file owner is notified. Reference GitHub docs for more details: 437 | # https://help.github.com/en/articles/about-code-owners 438 | 439 | 440 | # Annotations at the top of file 441 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 442 | 443 | # Team-specific owned globs 444 | /app/services/bar_stuff/** @MyOrg/bar-team 445 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 446 | 447 | # Owner metadata key in package.json 448 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 449 | 450 | # Team YML ownership 451 | /config/teams/bar.yml @MyOrg/bar-team 452 | CODEOWNERS 453 | 454 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 455 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 456 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 457 | expect(e.message).to eq <<~EXPECTED.chomp 458 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 459 | 460 | CODEOWNERS should contain the following lines, but does not: 461 | - "/packs/my_pack/owned_file.rb @MyOrg/bar-team" 462 | - "/config/teams/foo.yml @MyOrg/foo-team" 463 | - "# Owner in .codeowner" 464 | - "/directory/owner/**/** @MyOrg/bar-team" 465 | - "/directory/owner/(my_folder)/**/** @MyOrg/foo-team" 466 | - "# Owner metadata key in package.yml" 467 | - "/packs/my_other_package/**/** @MyOrg/bar-team" 468 | 469 | See https://github.com/rubyatscale/code_ownership#README.md for more details 470 | EXPECTED 471 | end 472 | end 473 | end 474 | 475 | context 'in an application with a CODEOWNERS file with no issue' do 476 | before { create_non_empty_application } 477 | 478 | it 'prints out the diff' do 479 | FileUtils.mkdir('.github') 480 | codeowners_path.write <<~CODEOWNERS 481 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 482 | # This file was automatically generated by "bin/codeownership validate". 483 | # 484 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 485 | # teams. This is useful when developers create Pull Requests since the 486 | # code/file owner is notified. Reference GitHub docs for more details: 487 | # https://help.github.com/en/articles/about-code-owners 488 | 489 | 490 | # Annotations at the top of file 491 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 492 | /packs/my_pack/owned_file.rb @MyOrg/bar-team 493 | 494 | # Owner in .codeowner 495 | /directory/owner/**/** @MyOrg/bar-team 496 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 497 | 498 | # Owner metadata key in package.yml 499 | /packs/my_other_package/**/** @MyOrg/bar-team 500 | 501 | # Team-specific owned globs 502 | /app/services/bar_stuff/** @MyOrg/bar-team 503 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 504 | 505 | # Owner metadata key in package.json 506 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 507 | 508 | # Team YML ownership 509 | /config/teams/bar.yml @MyOrg/bar-team 510 | /config/teams/foo.yml @MyOrg/foo-team 511 | CODEOWNERS 512 | 513 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 514 | expect { CodeOwnership.validate!(autocorrect: false) }.to_not raise_error 515 | end 516 | end 517 | 518 | context 'in an application with an unsorted CODEOWNERS file' do 519 | before { create_non_empty_application } 520 | 521 | it 'prints out the diff' do 522 | FileUtils.mkdir('.github') 523 | codeowners_path.write <<~CODEOWNERS 524 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 525 | # This file was automatically generated by "bin/codeownership validate". 526 | # 527 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 528 | # teams. This is useful when developers create Pull Requests since the 529 | # code/file owner is notified. Reference GitHub docs for more details: 530 | # https://help.github.com/en/articles/about-code-owners 531 | 532 | 533 | # Annotations at the top of file 534 | /packs/my_pack/owned_file.rb @MyOrg/bar-team 535 | /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team 536 | 537 | # Owner in .codeowner 538 | /directory/owner/**/** @MyOrg/bar-team 539 | /directory/owner/(my_folder)/**/** @MyOrg/foo-team 540 | 541 | # Owner metadata key in package.yml 542 | /packs/my_other_package/**/** @MyOrg/bar-team 543 | 544 | # Team-specific owned globs 545 | /app/services/bar_stuff/** @MyOrg/bar-team 546 | /frontend/javascripts/bar_stuff/** @MyOrg/bar-team 547 | 548 | # Owner metadata key in package.json 549 | /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team 550 | 551 | # Team YML ownership 552 | /config/teams/bar.yml @MyOrg/bar-team 553 | /config/teams/foo.yml @MyOrg/foo-team 554 | CODEOWNERS 555 | 556 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 557 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 558 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 559 | expect(e.message).to eq <<~EXPECTED.chomp 560 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 561 | 562 | There may be extra lines, or lines are out of order. 563 | You can try to regenerate the CODEOWNERS file from scratch: 564 | 1) `rm .github/CODEOWNERS` 565 | 2) `bin/codeownership validate` 566 | 567 | See https://github.com/rubyatscale/code_ownership#README.md for more details 568 | EXPECTED 569 | end 570 | end 571 | end 572 | 573 | context 'in an application with a CODEOWNERS file that has a reference to a github team that no longer exists' do 574 | before do 575 | write_configuration 576 | 577 | write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) 578 | # @team Bar 579 | CONTENTS 580 | 581 | write_file('config/teams/bar.yml', <<~CONTENTS) 582 | name: Bar 583 | github: 584 | team: '@MyOrg/bar-team' 585 | CONTENTS 586 | end 587 | 588 | it 'prints out the diff' do 589 | FileUtils.mkdir('.github') 590 | codeowners_path.write <<~CODEOWNERS 591 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 592 | # This file was automatically generated by "bin/codeownership validate". 593 | # 594 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 595 | # teams. This is useful when developers create Pull Requests since the 596 | # code/file owner is notified. Reference GitHub docs for more details: 597 | # https://help.github.com/en/articles/about-code-owners 598 | 599 | 600 | # Annotations at the top of file 601 | /packs/my_pack/owned_file.rb @MyOrg/this-team-does-not-exist 602 | CODEOWNERS 603 | 604 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 605 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 606 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 607 | expect(e.message).to eq <<~EXPECTED.chomp 608 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 609 | 610 | CODEOWNERS should contain the following lines, but does not: 611 | - "/packs/my_pack/owned_file.rb @MyOrg/bar-team" 612 | - "# Team YML ownership" 613 | - "/config/teams/bar.yml @MyOrg/bar-team" 614 | 615 | CODEOWNERS should not contain the following lines, but it does: 616 | - "/packs/my_pack/owned_file.rb @MyOrg/this-team-does-not-exist" 617 | 618 | See https://github.com/rubyatscale/code_ownership#README.md for more details 619 | EXPECTED 620 | end 621 | end 622 | end 623 | 624 | context 'in an application with a CODEOWNERS file that has a reference to a file that no longer exists' do 625 | before do 626 | write_configuration 627 | 628 | write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) 629 | # @team Bar 630 | CONTENTS 631 | 632 | write_file('config/teams/bar.yml', <<~CONTENTS) 633 | name: Bar 634 | github: 635 | team: '@MyOrg/bar-team' 636 | CONTENTS 637 | end 638 | 639 | it 'prints out the diff' do 640 | FileUtils.mkdir('.github') 641 | codeowners_path.write <<~CODEOWNERS 642 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 643 | # This file was automatically generated by "bin/codeownership validate". 644 | # 645 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 646 | # teams. This is useful when developers create Pull Requests since the 647 | # code/file owner is notified. Reference GitHub docs for more details: 648 | # https://help.github.com/en/articles/about-code-owners 649 | 650 | 651 | # Annotations at the top of file 652 | /packs/my_pack/owned_file.rb @MyOrg/bar-team 653 | /packs/my_pack/deleted_file.rb @MyOrg/bar-team 654 | CODEOWNERS 655 | 656 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 657 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 658 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 659 | expect(e.message).to eq <<~EXPECTED.chomp 660 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 661 | 662 | CODEOWNERS should contain the following lines, but does not: 663 | - "# Team YML ownership" 664 | - "/config/teams/bar.yml @MyOrg/bar-team" 665 | 666 | CODEOWNERS should not contain the following lines, but it does: 667 | - "/packs/my_pack/deleted_file.rb @MyOrg/bar-team" 668 | 669 | See https://github.com/rubyatscale/code_ownership#README.md for more details 670 | EXPECTED 671 | end 672 | end 673 | end 674 | 675 | context 'in an application with a CODEOWNERS file that has a reference to a file that has had an annotation removed' do 676 | before do 677 | write_configuration 678 | 679 | write_file('packs/my_pack/had_annotation_file.rb', '') 680 | 681 | write_file('config/teams/bar.yml', <<~CONTENTS) 682 | name: Bar 683 | github: 684 | team: '@MyOrg/bar-team' 685 | CONTENTS 686 | end 687 | 688 | it 'prints out the diff' do 689 | FileUtils.mkdir('.github') 690 | codeowners_path.write <<~CODEOWNERS 691 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 692 | # This file was automatically generated by "bin/codeownership validate". 693 | # 694 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 695 | # teams. This is useful when developers create Pull Requests since the 696 | # code/file owner is notified. Reference GitHub docs for more details: 697 | # https://help.github.com/en/articles/about-code-owners 698 | 699 | # Annotations at the top of file 700 | /packs/my_pack/had_annotation_file.rb @MyOrg/bar-team 701 | 702 | # Team YML ownership 703 | /config/teams/bar.yml @MyOrg/bar-team 704 | CODEOWNERS 705 | 706 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 707 | expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| 708 | expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError 709 | expect(e.message).to eq <<~EXPECTED.chomp 710 | Some files are missing ownership: 711 | 712 | - packs/my_pack/had_annotation_file.rb 713 | 714 | CODEOWNERS out of date. Run `bin/codeownership validate` to update the CODEOWNERS file 715 | 716 | CODEOWNERS should not contain the following lines, but it does: 717 | - "# Annotations at the top of file" 718 | - "/packs/my_pack/had_annotation_file.rb @MyOrg/bar-team" 719 | 720 | See https://github.com/rubyatscale/code_ownership#README.md for more details 721 | EXPECTED 722 | end 723 | end 724 | end 725 | 726 | context 'validating codeowners using --diff in an application with a CODEOWNERS file' do 727 | before do 728 | write_configuration 729 | 730 | write_file('packs/my_pack/had_annotation_file.rb', <<~CONTENTS) 731 | # @team Bar 732 | CONTENTS 733 | 734 | write_file('config/teams/bar.yml', <<~CONTENTS) 735 | name: Bar 736 | github: 737 | team: '@MyOrg/bar-team' 738 | CONTENTS 739 | end 740 | 741 | it 'prints out the diff' do 742 | FileUtils.mkdir('.github') 743 | codeowners_path.write <<~CODEOWNERS 744 | # STOP! - DO NOT EDIT THIS FILE MANUALLY 745 | # This file was automatically generated by "bin/codeownership validate". 746 | # 747 | # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub 748 | # teams. This is useful when developers create Pull Requests since the 749 | # code/file owner is notified. Reference GitHub docs for more details: 750 | # https://help.github.com/en/articles/about-code-owners 751 | 752 | 753 | # Annotations at the top of file 754 | /packs/my_pack/had_annotation_file.rb @MyOrg/bar-team 755 | 756 | # Team YML ownership 757 | /config/teams/bar.yml @MyOrg/bar-team 758 | CODEOWNERS 759 | 760 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 761 | expect { CodeOwnership.validate!(autocorrect: false, files: []) }.to_not raise_error 762 | end 763 | end 764 | end 765 | 766 | context 'code_ownership.yml has skip_codeowners_validation set' do 767 | before do 768 | write_configuration('skip_codeowners_validation' => true) 769 | end 770 | 771 | it 'skips validating the codeowners file' do 772 | expect(codeowners_path).to_not exist 773 | expect_any_instance_of(codeowners_validation).to_not receive(:`) 774 | expect { CodeOwnership.validate!(autocorrect: false) }.to_not raise_error 775 | expect(codeowners_path).to_not exist 776 | end 777 | end 778 | end 779 | 780 | describe 'uniqueness of github teams' do 781 | context 'when the CodeTeam has a github.team key' do 782 | before do 783 | write_configuration 784 | 785 | write_file('config/teams/bar.yml', <<~CONTENTS) 786 | name: Bar 787 | github: 788 | team: '@MyOrg/bar-team' 789 | CONTENTS 790 | 791 | write_file('config/teams/foo.yml', <<~CONTENTS) 792 | name: Bar 793 | github: 794 | team: '@MyOrg/bar-team' 795 | CONTENTS 796 | end 797 | 798 | it 'expect code teams validations to fail' do 799 | expect(CodeTeams.validation_errors(CodeTeams.all)).to eq([ 800 | 'More than 1 definition for Bar found', 801 | "The following teams are specified multiple times:\nEach code team must have a unique GitHub team in order to write the CODEOWNERS file correctly.\n\n@MyOrg/bar-team\n" 802 | ]) 803 | end 804 | end 805 | 806 | context 'when the CodeTeam does not have a github.team key' do 807 | before do 808 | write_configuration 809 | 810 | write_file('config/teams/bar.yml', <<~CONTENTS) 811 | name: Bar 812 | CONTENTS 813 | 814 | write_file('config/teams/foo.yml', <<~CONTENTS) 815 | name: Bar 816 | CONTENTS 817 | end 818 | 819 | it 'does not report CodeTeams without github.teams key' do 820 | expect(CodeTeams.validation_errors(CodeTeams.all)).to eq([ 821 | 'More than 1 definition for Bar found' 822 | ]) 823 | end 824 | end 825 | end 826 | 827 | describe 'require_github_teams configuration option' do 828 | before do 829 | write_configuration('require_github_teams' => require_github_teams) 830 | 831 | write_file('config/teams/foo.yml', <<~CONTENTS) 832 | name: Foo 833 | CONTENTS 834 | 835 | write_file('config/teams/bar.yml', <<~CONTENTS) 836 | name: Bar 837 | CONTENTS 838 | end 839 | 840 | context 'when require_github_teams is enabled' do 841 | let(:require_github_teams) { true } 842 | 843 | it 'reports CodeTeams without github.team keys' do 844 | errors = CodeTeams.validation_errors(CodeTeams.all) 845 | expect(errors.length).to eq(1) 846 | expect(errors.first).to include('The following teams are missing `github.team` entries:') 847 | expect(errors.first).to include('config/teams/bar.yml') 848 | expect(errors.first).to include('config/teams/foo.yml') 849 | end 850 | end 851 | 852 | context 'when require_github_teams is disabled' do 853 | let(:require_github_teams) { false } 854 | 855 | it 'does not report any errors' do 856 | expect(CodeTeams.validation_errors(CodeTeams.all)).to be_empty 857 | end 858 | end 859 | end 860 | end 861 | end 862 | -------------------------------------------------------------------------------- /spec/lib/code_ownership_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CodeOwnership do 2 | # Look at individual validations spec to see other validations that ship with CodeOwnership 3 | describe '.validate!' do 4 | describe 'teams must exist validation' do 5 | before do 6 | write_file('config/teams/bar.yml', <<~CONTENTS) 7 | name: Bar 8 | CONTENTS 9 | 10 | write_configuration 11 | end 12 | 13 | context 'directory with [] characters' do 14 | before do 15 | write_file('app/services/.codeowner', <<~CONTENTS) 16 | Bar 17 | CONTENTS 18 | write_file('app/services/test/some_unowned_file.rb', '') 19 | write_file('app/services/[test]/some_unowned_file.rb', '') 20 | end 21 | 22 | it 'has no validation errors' do 23 | expect { CodeOwnership.validate!(files: ['app/services/test/some_unowned_file.rb']) }.to_not raise_error 24 | expect { CodeOwnership.validate!(files: ['app/services/[test]/some_unowned_file.rb']) }.to_not raise_error 25 | end 26 | end 27 | 28 | context 'directory with [] characters containing a .codeowner file' do 29 | before do 30 | write_file('app/services/[test]/.codeowner', <<~CONTENTS) 31 | Bar 32 | CONTENTS 33 | write_file('app/services/[test]/some_file.rb', '') 34 | end 35 | 36 | it 'has no validation errors' do 37 | expect { CodeOwnership.validate!(files: ['app/services/[test]/some_file.rb']) }.to_not raise_error 38 | end 39 | end 40 | 41 | context 'file ownership with [] characters' do 42 | before do 43 | write_file('app/services/[test]/some_file.ts', <<~TYPESCRIPT) 44 | // @team Bar 45 | // Countries 46 | TYPESCRIPT 47 | end 48 | 49 | it 'has no validation errors' do 50 | expect { CodeOwnership.validate!(files: ['app/services/withoutbracket/some_other_file.rb']) }.to_not raise_error 51 | expect { CodeOwnership.validate!(files: ['app/services/[test]/some_other_file.rb']) }.to_not raise_error 52 | expect { CodeOwnership.validate!(files: ['app/services/*/some_other_file.rb']) }.to_not raise_error 53 | expect { CodeOwnership.validate!(files: ['app/*/[test]/some_other_file.rb']) }.to_not raise_error 54 | expect { CodeOwnership.validate! }.to_not raise_error 55 | end 56 | end 57 | 58 | context 'invalid team in a file annotation' do 59 | before do 60 | write_file('app/some_file.rb', <<~CONTENTS) 61 | # @team Foo 62 | CONTENTS 63 | end 64 | 65 | it 'lets the user know the team cannot be found in the file' do 66 | expect { CodeOwnership.validate! }.to raise_error do |e| 67 | expect(e).to be_a StandardError 68 | expect(e.message).to eq <<~EXPECTED.chomp 69 | Could not find team with name: `Foo` in app/some_file.rb. Make sure the team is one of `["Bar"]` 70 | EXPECTED 71 | end 72 | end 73 | end 74 | 75 | context 'invalid team in a package.yml' do 76 | before do 77 | write_file('packs/my_pack/package.yml', <<~CONTENTS) 78 | owner: Foo 79 | CONTENTS 80 | end 81 | 82 | it 'lets the user know the team cannot be found in the package.yml' do 83 | expect { CodeOwnership.validate! }.to raise_error do |e| 84 | expect(e).to be_a StandardError 85 | expect(e.message).to eq <<~EXPECTED.chomp 86 | Could not find team with name: `Foo` in packs/my_pack/package.yml. Make sure the team is one of `["Bar"]` 87 | EXPECTED 88 | end 89 | end 90 | end 91 | 92 | context 'invalid team in a package.yml using metadata' do 93 | before do 94 | write_file('packs/my_pack/package.yml', <<~CONTENTS) 95 | metadata: 96 | owner: Foo 97 | CONTENTS 98 | end 99 | 100 | it 'lets the user know the team cannot be found in the package.yml' do 101 | expect { CodeOwnership.validate! }.to raise_error do |e| 102 | expect(e).to be_a StandardError 103 | expect(e.message).to eq <<~EXPECTED.chomp 104 | Could not find team with name: `Foo` in packs/my_pack/package.yml. Make sure the team is one of `["Bar"]` 105 | EXPECTED 106 | end 107 | end 108 | end 109 | 110 | context 'invalid team in a package.json' do 111 | before do 112 | write_file('frontend/javascripts/my_package/package.json', <<~CONTENTS) 113 | { 114 | "metadata": { 115 | "owner": "Foo" 116 | } 117 | } 118 | CONTENTS 119 | end 120 | 121 | it 'lets the user know the team cannot be found in the package.json' do 122 | expect { CodeOwnership.validate! }.to raise_error do |e| 123 | expect(e).to be_a StandardError 124 | expect(e.message).to eq <<~EXPECTED.chomp 125 | Could not find team with name: `Foo` in frontend/javascripts/my_package. Make sure the team is one of `["Bar"]` 126 | EXPECTED 127 | end 128 | end 129 | end 130 | end 131 | 132 | context 'file is unowned' do 133 | before do 134 | write_file('config/teams/bar.yml', <<~CONTENTS) 135 | name: Bar 136 | CONTENTS 137 | 138 | write_configuration 139 | 140 | write_file('app/services/autogenerated_code/some_unowned_file.rb', '') 141 | end 142 | 143 | it 'has no validation errors' do 144 | expect { CodeOwnership.validate!(files: ['app/services/autogenerated_code/some_unowned_file.rb']) }.to raise_error do |e| 145 | expect(e.message).to eq <<~MSG.chomp 146 | Some files are missing ownership: 147 | 148 | - app/services/autogenerated_code/some_unowned_file.rb 149 | 150 | See https://github.com/rubyatscale/code_ownership#README.md for more details 151 | MSG 152 | end 153 | end 154 | 155 | context 'ignored file passed in that is ignored' do 156 | before do 157 | write_configuration('unowned_globs' => ['app/services/autogenerated_code/**/**', 'vendor/bundle/**/**']) 158 | end 159 | 160 | it 'has no validation errors' do 161 | expect { CodeOwnership.validate!(files: ['app/services/autogenerated_code/some_unowned_file.rb']) }.to_not raise_error 162 | end 163 | end 164 | end 165 | end 166 | 167 | # See tests for individual ownership_mappers to understand behavior for each mapper 168 | describe '.for_file' do 169 | describe 'path formatting expectations' do 170 | # All file paths must be clean paths relative to the root: https://apidock.com/ruby/Pathname/cleanpath 171 | it 'will not find the ownership of a file that is not a cleanpath' do 172 | expect(CodeOwnership.for_file('packs/my_pack/owned_file.rb')).to eq CodeTeams.find('Bar') 173 | expect(CodeOwnership.for_file('./packs/my_pack/owned_file.rb')).to eq nil 174 | end 175 | end 176 | 177 | context '.codeowner in a directory with [] characters' do 178 | before do 179 | write_file('app/javascript/[test]/.codeowner', <<~CONTENTS) 180 | Bar 181 | CONTENTS 182 | write_file('app/javascript/[test]/test.js', '') 183 | end 184 | 185 | it 'properly assigns ownership' do 186 | expect(CodeOwnership.for_file('app/javascript/[test]/test.js')).to eq CodeTeams.find('Bar') 187 | end 188 | end 189 | 190 | before { create_non_empty_application } 191 | end 192 | 193 | describe '.for_backtrace' do 194 | before do 195 | create_files_with_defined_classes 196 | write_configuration 197 | end 198 | 199 | context 'excluded_teams is not passed in as an input parameter' do 200 | it 'finds the right team' do 201 | expect { MyFile.raise_error }.to raise_error do |ex| 202 | expect(CodeOwnership.for_backtrace(ex.backtrace)).to eq CodeTeams.find('Bar') 203 | end 204 | end 205 | end 206 | 207 | context 'excluded_teams is passed in as an input parameter' do 208 | it 'ignores the first part of the stack trace and finds the next viable owner' do 209 | expect { MyFile.raise_error }.to raise_error do |ex| 210 | team_to_exclude = CodeTeams.find('Bar') 211 | expect(CodeOwnership.for_backtrace(ex.backtrace, excluded_teams: [team_to_exclude])).to eq CodeTeams.find('Foo') 212 | end 213 | end 214 | end 215 | end 216 | 217 | describe '.first_owned_file_for_backtrace' do 218 | before do 219 | write_configuration 220 | create_files_with_defined_classes 221 | end 222 | 223 | context 'excluded_teams is not passed in as an input parameter' do 224 | it 'finds the right team' do 225 | expect { MyFile.raise_error }.to raise_error do |ex| 226 | expect(CodeOwnership.first_owned_file_for_backtrace(ex.backtrace)).to eq [CodeTeams.find('Bar'), 'app/my_error.rb'] 227 | end 228 | end 229 | end 230 | 231 | context 'excluded_teams is not passed in as an input parameter' do 232 | it 'finds the right team' do 233 | expect { MyFile.raise_error }.to raise_error do |ex| 234 | team_to_exclude = CodeTeams.find('Bar') 235 | expect(CodeOwnership.first_owned_file_for_backtrace(ex.backtrace, excluded_teams: [team_to_exclude])).to eq [CodeTeams.find('Foo'), 'app/my_file.rb'] 236 | end 237 | end 238 | end 239 | 240 | context 'when nothing is owned' do 241 | it 'returns nil' do 242 | expect { raise 'opsy' }.to raise_error do |ex| 243 | expect(CodeOwnership.first_owned_file_for_backtrace(ex.backtrace)).to be_nil 244 | end 245 | end 246 | end 247 | end 248 | 249 | describe '.for_class' do 250 | before do 251 | create_files_with_defined_classes 252 | write_configuration 253 | end 254 | 255 | it 'can find the right owner for a class' do 256 | expect(CodeOwnership.for_class(MyFile)).to eq CodeTeams.find('Foo') 257 | end 258 | 259 | it 'memoizes the values' do 260 | expect(CodeOwnership.for_class(MyFile)).to eq CodeTeams.find('Foo') 261 | allow(CodeOwnership).to receive(:for_file) 262 | allow(Object).to receive(:const_source_location) 263 | expect(CodeOwnership.for_class(MyFile)).to eq CodeTeams.find('Foo') 264 | 265 | # Memoization should avoid these calls 266 | expect(CodeOwnership).to_not have_received(:for_file) 267 | expect(Object).to_not have_received(:const_source_location) 268 | end 269 | 270 | it 'returns nil if the class constant cannot be found' do 271 | allow(CodeOwnership).to receive(:for_file) 272 | allow(Object).to receive(:const_source_location).and_raise(NameError) 273 | expect(CodeOwnership.for_class(MyFile)).to eq nil 274 | end 275 | end 276 | 277 | describe '.for_team' do 278 | before { create_non_empty_application } 279 | 280 | it 'prints out ownership information for the given team' do 281 | expect(CodeOwnership.for_team('Bar')).to eq <<~OWNERSHIP 282 | # Code Ownership Report for `Bar` Team 283 | ## Annotations at the top of file 284 | - frontend/javascripts/packages/my_package/owned_file.jsx 285 | - packs/my_pack/owned_file.rb 286 | 287 | ## Team-specific owned globs 288 | - app/services/bar_stuff/** 289 | - frontend/javascripts/bar_stuff/** 290 | 291 | ## Owner in .codeowner 292 | - directory/owner/**/** 293 | 294 | ## Owner metadata key in package.yml 295 | - packs/my_other_package/**/** 296 | 297 | ## Owner metadata key in package.json 298 | - frontend/javascripts/packages/my_other_package/**/** 299 | 300 | ## Team YML ownership 301 | - config/teams/bar.yml 302 | OWNERSHIP 303 | end 304 | end 305 | 306 | describe 'pack level ownership' do 307 | # These errors show up from `bin/packwerk validate`, so using the `ApplicationValidator` to test 308 | let(:validation_result) do 309 | configuration = Packwerk::Configuration.from_path 310 | package_set = Packwerk::PackageSet.load_all_from( 311 | configuration.root_path, 312 | package_pathspec: configuration.package_paths 313 | ) 314 | Packwerk.const_get(:ApplicationValidator).new.call(package_set, configuration) 315 | end 316 | 317 | before do 318 | # We stub this to avoid needing to set up a Rails app 319 | allow(Packwerk::RailsLoadPaths).to receive(:for).and_return({ 'packs/my_pack/app/services' => Object }) 320 | write_pack('.') 321 | write_pack('packs/my_pack', { 'owner' => 'Foo' }) 322 | write_file('packs/my_pack/app/services/my_pack.rb') 323 | end 324 | 325 | it 'does not create a validation error' do 326 | expect(validation_result.error_value).to be_nil 327 | expect(validation_result.ok?).to eq true 328 | end 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'debug' 3 | require 'packwerk' 4 | require 'code_ownership' 5 | require 'code_teams' 6 | require 'packs/rspec/support' 7 | require_relative 'support/application_fixtures' 8 | 9 | RSpec.configure do |config| 10 | # Enable flags like --only-failures and --next-failure 11 | config.example_status_persistence_file_path = '.rspec_status' 12 | 13 | # Disable RSpec exposing methods globally on `Module` and `main` 14 | config.disable_monkey_patching! 15 | 16 | config.expect_with :rspec do |c| 17 | c.syntax = :expect 18 | end 19 | 20 | config.include_context 'application fixtures' 21 | 22 | config.before do |c| 23 | allow_any_instance_of(CodeOwnership.const_get(:Private)::Validations::GithubCodeownersUpToDate).to receive(:`) 24 | allow(CodeOwnership::Cli).to receive(:`) 25 | codeowners_path.delete if codeowners_path.exist? 26 | 27 | unless c.metadata[:do_not_bust_cache] 28 | CodeOwnership.bust_caches! 29 | CodeTeams.bust_caches! 30 | allow(CodeTeams::Plugin).to receive(:registry).and_return({}) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/support/application_fixtures.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'application fixtures' do 2 | let(:codeowners_path) { Pathname.pwd.join('.github/CODEOWNERS') } 3 | 4 | def write_configuration(owned_globs: nil, **kwargs) 5 | owned_globs ||= ['{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx,json,yml}'] 6 | config = { 7 | 'owned_globs' => owned_globs, 8 | 'unowned_globs' => ['config/code_ownership.yml'] 9 | }.merge(kwargs) 10 | write_file('config/code_ownership.yml', config.to_yaml) 11 | end 12 | 13 | let(:create_non_empty_application) do 14 | write_configuration 15 | 16 | write_file('frontend/javascripts/packages/my_package/owned_file.jsx', <<~CONTENTS) 17 | // @team Bar 18 | CONTENTS 19 | 20 | write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) 21 | # @team Bar 22 | CONTENTS 23 | 24 | write_file('directory/owner/.codeowner', <<~CONTENTS) 25 | Bar 26 | CONTENTS 27 | write_file('directory/owner/some_directory_file.ts') 28 | write_file('directory/owner/(my_folder)/.codeowner', <<~CONTENTS) 29 | Foo 30 | CONTENTS 31 | write_file('directory/owner/(my_folder)/some_other_file.ts') 32 | 33 | write_file('frontend/javascripts/packages/my_other_package/package.json', <<~CONTENTS) 34 | { 35 | "name": "@gusto/my_package", 36 | "metadata": { 37 | "owner": "Bar" 38 | } 39 | } 40 | CONTENTS 41 | write_file('frontend/javascripts/packages/my_other_package/my_file.jsx') 42 | 43 | write_file('config/teams/foo.yml', <<~CONTENTS) 44 | name: Foo 45 | github: 46 | team: '@MyOrg/foo-team' 47 | CONTENTS 48 | 49 | write_file('config/teams/bar.yml', <<~CONTENTS) 50 | name: Bar 51 | github: 52 | team: '@MyOrg/bar-team' 53 | owned_globs: 54 | - app/services/bar_stuff/** 55 | - frontend/javascripts/bar_stuff/** 56 | CONTENTS 57 | 58 | write_file('app/services/bar_stuff/thing.rb') 59 | write_file('frontend/javascripts/bar_stuff/thing.jsx') 60 | 61 | write_file('packs/my_other_package/package.yml', <<~CONTENTS) 62 | enforce_dependency: true 63 | enforce_privacy: true 64 | owner: Bar 65 | CONTENTS 66 | 67 | write_file('package.yml', <<~CONTENTS) 68 | enforce_dependency: true 69 | enforce_privacy: true 70 | CONTENTS 71 | 72 | write_file('packs/my_other_package/my_file.rb') 73 | end 74 | 75 | let(:create_files_with_defined_classes) do 76 | write_file('app/my_file.rb', <<~CONTENTS) 77 | # @team Foo 78 | 79 | require_relative 'my_error' 80 | 81 | class MyFile 82 | def self.raise_error 83 | MyError.raise_error 84 | end 85 | end 86 | CONTENTS 87 | 88 | write_file('app/my_error.rb', <<~CONTENTS) 89 | # @team Bar 90 | 91 | class MyError 92 | def self.raise_error 93 | raise "some error" 94 | end 95 | end 96 | CONTENTS 97 | 98 | write_file('config/teams/foo.yml', <<~CONTENTS) 99 | name: Foo 100 | CONTENTS 101 | 102 | write_file('config/teams/bar.yml', <<~CONTENTS) 103 | name: Bar 104 | CONTENTS 105 | 106 | # Some of the tests use the `SequoiaTree` constant. Since the implementation leverages: 107 | # `path = Object.const_source_location(klass.to_s)&.first`, we want to make sure that 108 | # we re-require the constant each time, since `RSpecTempfiles` changes where the file lives with each test 109 | Object.send(:remove_const, :MyFile) if defined? MyFile # : 110 | Object.send(:remove_const, :MyError) if defined? MyError # : 111 | require Pathname.pwd.join('app/my_file') 112 | end 113 | end 114 | --------------------------------------------------------------------------------