From ebbcfc3e129bb62bfff13d640da8050682dc9591 Mon Sep 17 00:00:00 2001 From: Alex Evanczuk Date: Mon, 9 May 2022 14:59:09 -0400 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 43 ++ .github/workflows/publish.yml | 21 + .gitignore | 11 + .rspec | 3 + CHANGELOG.md | 1 + Gemfile | 3 + Gemfile.lock | 96 +++ LICENSE | 21 + README.md | 102 ++- bin/codeownership | 5 + code_ownership.gemspec | 38 + config/code_ownership.yml | 2 + lib/code_ownership.rb | 129 ++++ lib/code_ownership/cli.rb | 60 ++ lib/code_ownership/private.rb | 124 +++ lib/code_ownership/private/configuration.rb | 37 + .../ownership_mappers/file_annotations.rb | 119 +++ .../private/ownership_mappers/interface.rb | 50 ++ .../ownership_mappers/js_package_ownership.rb | 121 +++ .../ownership_mappers/package_ownership.rb | 121 +++ .../private/ownership_mappers/team_globs.rb | 68 ++ .../private/parse_js_packages.rb | 59 ++ .../private/team_plugins/github.rb | 24 + .../private/team_plugins/ownership.rb | 17 + .../private/validations/files_have_owners.rb | 34 + .../validations/files_have_unique_owners.rb | 32 + .../github_codeowners_up_to_date.rb | 85 ++ .../private/validations/interface.rb | 18 + sorbet/config | 4 + sorbet/rbi/gems/bigrails-teams@0.1.0.rbi | 120 +++ sorbet/rbi/gems/parse_packwerk@0.7.0.rbi | 111 +++ sorbet/rbi/todo.rbi | 6 + spec/lib/code_ownership/cli_spec.rb | 26 + spec/lib/code_ownership_spec.rb | 725 ++++++++++++++++++ spec/spec_helper.rb | 42 + spec/support/application_fixtures.rb | 93 +++ 36 files changed, 2570 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100755 bin/codeownership create mode 100644 code_ownership.gemspec create mode 100644 config/code_ownership.yml create mode 100644 lib/code_ownership.rb create mode 100644 lib/code_ownership/cli.rb create mode 100644 lib/code_ownership/private.rb create mode 100644 lib/code_ownership/private/configuration.rb create mode 100644 lib/code_ownership/private/ownership_mappers/file_annotations.rb create mode 100644 lib/code_ownership/private/ownership_mappers/interface.rb create mode 100644 lib/code_ownership/private/ownership_mappers/js_package_ownership.rb create mode 100644 lib/code_ownership/private/ownership_mappers/package_ownership.rb create mode 100644 lib/code_ownership/private/ownership_mappers/team_globs.rb create mode 100644 lib/code_ownership/private/parse_js_packages.rb create mode 100644 lib/code_ownership/private/team_plugins/github.rb create mode 100644 lib/code_ownership/private/team_plugins/ownership.rb create mode 100644 lib/code_ownership/private/validations/files_have_owners.rb create mode 100644 lib/code_ownership/private/validations/files_have_unique_owners.rb create mode 100644 lib/code_ownership/private/validations/github_codeowners_up_to_date.rb create mode 100644 lib/code_ownership/private/validations/interface.rb create mode 100644 sorbet/config create mode 100644 sorbet/rbi/gems/bigrails-teams@0.1.0.rbi create mode 100644 sorbet/rbi/gems/parse_packwerk@0.7.0.rbi create mode 100644 sorbet/rbi/todo.rbi create mode 100644 spec/lib/code_ownership/cli_spec.rb create mode 100644 spec/lib/code_ownership_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/application_fixtures.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0132dfb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + ruby: + - 2.7 + # See comment comes from https://github.com/ruby/setup-ruby#matrix-of-ruby-versions + # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' + - '3.0' + - 3.1 + - head + env: + BUNDLE_GEMFILE: Gemfile + name: "Tests: Ruby ${{ matrix.ruby }}" + steps: + - uses: actions/checkout@5126516654c75f76bca1de45dd82a3006d8890f9 + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@bd94d6a504586da892a5753afdd1480096ed30df + with: + ruby-version: ${{ matrix.ruby }} + - name: Run tests + run: | + gem install bundler + bundle install --jobs 4 --retry 3 + bundle exec rspec + static-type-checking: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@5126516654c75f76bca1de45dd82a3006d8890f9 + - name: Set up Ruby + uses: ruby/setup-ruby@bd94d6a504586da892a5753afdd1480096ed30df + with: + ruby-version: head + - name: Run static type checks + run: | + gem install bundler + bundle install --jobs 4 --retry 3 + bundle exec srb tc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..40538ec --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: Publish Gem + +on: + push: + branches: + - "main" + tags: + - v* +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@5126516654c75f76bca1de45dd82a3006d8890f9 + + - name: Release Gem + if: contains(github.ref, 'refs/tags/v') + uses: cadwallion/publish-rubygems-action@8f9e0538302643309e4e43bf48cd34173ca48cfc + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0f5b222 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +See https://github.com/bigrails/code_ownership/releases diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fa75df1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..c86f360 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,96 @@ +PATH + remote: . + specs: + code_ownership (1.23.0) + bigrails-teams + parse_packwerk + sorbet-runtime + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + bigrails-teams (0.1.0) + sorbet-runtime + coderay (1.1.3) + diff-lcs (1.4.4) + method_source (1.0.0) + parse_packwerk (0.10.0) + sorbet-runtime + parser (3.1.2.0) + ast (~> 2.4.1) + pry (0.14.1) + coderay (~> 1.1) + method_source (~> 1.0) + rake (13.0.6) + rbi (0.0.14) + ast + parser (>= 2.6.4.0) + sorbet-runtime (>= 0.5.9204) + unparser + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-support (3.10.2) + sorbet (0.5.9889) + sorbet-static (= 0.5.9889) + sorbet-runtime (0.5.9889) + sorbet-static (0.5.9889-universal-darwin-14) + sorbet-static (0.5.9889-universal-darwin-15) + sorbet-static (0.5.9889-universal-darwin-16) + sorbet-static (0.5.9889-universal-darwin-17) + sorbet-static (0.5.9889-universal-darwin-18) + sorbet-static (0.5.9889-universal-darwin-19) + sorbet-static (0.5.9889-universal-darwin-20) + sorbet-static (0.5.9889-universal-darwin-21) + sorbet-static (0.5.9889-x86_64-linux) + spoom (1.1.11) + sorbet (>= 0.5.9204) + sorbet-runtime (>= 0.5.9204) + thor (>= 0.19.2) + tapioca (0.7.2) + bundler (>= 1.17.3) + pry (>= 0.12.2) + rbi (~> 0.0.0, >= 0.0.14) + sorbet-runtime (>= 0.5.9204) + sorbet-static (>= 0.5.9204) + spoom (~> 1.1.0, >= 1.1.11) + thor (>= 1.2.0) + yard-sorbet + thor (1.2.1) + unparser (0.6.4) + diff-lcs (~> 1.3) + parser (>= 3.1.0) + webrick (1.7.0) + yard (0.9.27) + webrick (~> 1.7.0) + yard-sorbet (0.6.1) + sorbet-runtime (>= 0.5) + yard (>= 0.9) + +PLATFORMS + arm64-darwin-20 + arm64-darwin-21 + ruby + x86_64-darwin-20 + x86_64-linux + +DEPENDENCIES + code_ownership! + pry + rake + rspec (~> 3.0) + sorbet + tapioca + +BUNDLED WITH + 2.3.9 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b94f527 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Gusto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 089929e..2157f33 100644 --- a/README.md +++ b/README.md @@ -1 +1,101 @@ -# code_ownership +# CodeOwnership +This gem helps engineering teams declare ownership of code. + +Check out `lib/code_ownership.rb` to see the public API. + +Check out `code_ownership_spec.rb` to see examples of how code ownership is used. + +## Usage: Declaring Ownership +There are three ways to declare code ownership using this gem. +### Package-Based Ownership +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: +```yml +enforce_dependency: true +enforce_privacy: true +metadata: + owner: Team +``` + +### Glob-Based Ownership +In your team's configured YML (see [`bigrails-teams`](https://github.com/bigrails/bigrails-teams)), you can set `owned_globs` to be a glob of files your team owns. For example, in `my_team.yml`: +```yml +name: My Team +owned_globs: + - app/services/stuff_belonging_to_my_team/**/** + - app/controllers/other_stuff_belonging_to_my_team/**/** +``` +### File-Annotation Based Ownership +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: +```ruby +# @team MyTeam +``` +## Usage: Reading CodeOwnership +### `for_file` +`CodeOwnership.for_file`, given a relative path to a file returns a `Teams::Team` if there is a team that owns the file, `nil` otherwise. + +```ruby +CodeOwnership.for_file('path/to/file/relative/to/application/root.rb') +``` + +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). + +See `code_ownership_spec.rb` for examples. + +### `for_backtrace` +`CodeOwnership.for_backtrace` can be given a backtrace and will either return `nil`, or a `Teams::Team`. + +```ruby +CodeOwnership.for_backtrace(exception.backtrace) +``` + +This will go through the backtrace, and return the first found owner of the files associated with frames within the backtrace. + +See `code_ownership_spec.rb` for an example. + +### `for_class` + +`CodeOwnership.for_class` can be given a class and will either return `nil`, or a `Teams::Team`. + +```ruby +CodeOwnership.for_class(MyClass.name) +``` + +Under the hood, this finds the file where the class is defined and returns the owner of that file. + +See `code_ownership_spec.rb` for an example. + +## Usage: Generating a `CODEOWNERS` file + +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. + +## Proper Configuration & Validation +CodeOwnership comes with a validation function to ensure the following things are true: +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. +2) All teams referenced as an owner for any file or package is a valid team (i.e. it's in the list of `Teams.all`). +3) All files have ownership. You can specify in `unowned_globs` to represent a TODO list of files to add ownership to. +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 `code_ownership.yml`. + +CodeOwnership also allows you to specify which globs and file extensions should be considered ownable. + +Here is an example `config/code_ownership.yml`. +```yml +owned_globs: + - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}' +unowned_globs: + - db/**/* + - app/services/some_file1.rb + - app/services/some_file2.rb + - frontend/javascripts/**/__generated__/**/* +``` +You can call the validation function with the Ruby API +```ruby +CodeOwnership.validate! +``` +or the CLI +``` +bin/codeownership validate +``` + +## Development + +Please add to `CHANGELOG.md` and this `README.md` when you make make changes. diff --git a/bin/codeownership b/bin/codeownership new file mode 100755 index 0000000..e711bda --- /dev/null +++ b/bin/codeownership @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# typed: strict + +require 'code_ownership' +CodeOwnership::Cli.run!(ARGV) diff --git a/code_ownership.gemspec b/code_ownership.gemspec new file mode 100644 index 0000000..5ccee7c --- /dev/null +++ b/code_ownership.gemspec @@ -0,0 +1,38 @@ +Gem::Specification.new do |spec| + spec.name = "code_ownership" + spec.version = '1.23.0' + spec.authors = ['Gusto Engineers'] + spec.email = ['dev@gusto.com'] + spec.summary = 'A gem to help engineering teams declare ownership of code' + spec.description = 'A gem to help engineering teams declare ownership of code' + spec.homepage = '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/bigrails/code_ownership' + spec.license = 'MIT' + + if spec.respond_to?(:metadata) + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = '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/bigrails/code_ownership' + spec.metadata['changelog_uri'] = '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/bigrails/code_ownership/releases' + spec.metadata['allowed_push_host'] = 'https://rubygems.org' + else + raise 'RubyGems 2.0 or newer is required to protect against ' \ + 'public gem pushes.' + end + # https://guides.rubygems.org/make-your-own-gem/#adding-an-executable + # and + # https://bundler.io/blog/2015/03/20/moving-bins-to-exe.html + spec.executables = ['codeownership'] + + # Specify which files should be added to the gem when it is released. + spec.files = Dir['README.md', 'sorbet/**/*', 'lib/**/*', 'bin/**/*'] + spec.require_paths = ['lib'] + + spec.add_dependency 'bigrails-teams' + spec.add_dependency 'parse_packwerk' + spec.add_dependency 'sorbet-runtime' + + spec.add_development_dependency 'rake' + spec.add_development_dependency 'pry' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'sorbet' + spec.add_development_dependency 'tapioca' +end diff --git a/config/code_ownership.yml b/config/code_ownership.yml new file mode 100644 index 0000000..6de127f --- /dev/null +++ b/config/code_ownership.yml @@ -0,0 +1,2 @@ +owned_globs: + - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}' diff --git a/lib/code_ownership.rb b/lib/code_ownership.rb new file mode 100644 index 0000000..69cf985 --- /dev/null +++ b/lib/code_ownership.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# typed: strict + +require 'set' +require 'teams' +require 'sorbet-runtime' +require 'json' +require 'parse_packwerk' +require 'code_ownership/cli' +require 'code_ownership/private' + +module CodeOwnership + extend self + extend T::Sig + extend T::Helpers + + requires_ancestor { Kernel } + + sig { params(file: String).returns(T.nilable(Teams::Team)) } + def for_file(file) + @for_file ||= T.let(@for_file, T.nilable(T::Hash[String, T.nilable(Teams::Team)])) + @for_file ||= {} + + return nil if file.start_with?('./') + return @for_file[file] if @for_file.key?(file) + + owner = T.let(nil, T.nilable(Teams::Team)) + + Private.mappers.each do |mapper| + owner = mapper.map_file_to_owner(file) + break if owner + end + + @for_file[file] = owner + end + + class InvalidCodeOwnershipConfigurationError < StandardError + end + + sig { params(filename: String).void } + def self.remove_file_annotation!(filename) + Private.file_annotations_mapper.remove_file_annotation!(filename) + end + + sig do + params( + files: T::Array[String], + autocorrect: T::Boolean, + stage_changes: T::Boolean + ).void + end + def validate!( + files: Private.tracked_files, + autocorrect: true, + stage_changes: true + ) + tracked_file_subset = Private.tracked_files & files + Private.validate!(files: tracked_file_subset, autocorrect: autocorrect, stage_changes: stage_changes) + end + + # Given a backtrace from either `Exception#backtrace` or `caller`, find the + # first line that corresponds to a file with assigned ownership + sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::Teams::Team]).returns(T.nilable(::Teams::Team)) } + def for_backtrace(backtrace, excluded_teams: []) + return unless backtrace + + # The pattern for a backtrace hasn't changed in forever and is considered + # stable: https://github.com/ruby/ruby/blob/trunk/vm_backtrace.c#L303-L317 + # + # This pattern matches a line like the following: + # + # ./app/controllers/some_controller.rb:43:in `block (3 levels) in create' + # + backtrace_line = %r{\A(#{Pathname.pwd}/|\./)? + (?.+) # Matches 'app/controllers/some_controller.rb' + : + (?\d+) # Matches '43' + :in\s + `(?.*)' # Matches "`block (3 levels) in create'" + \z}x + + backtrace.each do |line| + match = line.match(backtrace_line) + + if match + team = CodeOwnership.for_file(T.must(match[:file])) + if team && !excluded_teams.include?(team) + return team + end + end + end + nil + end + + sig { params(klass: T.nilable(T.any(Class, Module))).returns(T.nilable(::Teams::Team)) } + def for_class(klass) + @memoized_values ||= T.let(@memoized_values, T.nilable(T::Hash[String, T.nilable(::Teams::Team)])) + @memoized_values ||= {} + # We use key because the memoized value could be `nil` + if !@memoized_values.key?(klass.to_s) + path = Private.path_from_klass(klass) + return nil if path.nil? + + value_to_memoize = for_file(path) + @memoized_values[klass.to_s] = value_to_memoize + value_to_memoize + else + @memoized_values[klass.to_s] + end + end + + sig { params(package: ParsePackwerk::Package).returns(T.nilable(::Teams::Team)) } + def for_package(package) + Private::OwnershipMappers::PackageOwnership.new.owner_for_package(package) + end + + # Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change. + # Namely, the set of files, packages, and directories which are tracked for ownership should not change. + # The primary reason this is helpful is for clients of CodeOwnership who want to test their code, and each test context + # has different ownership and tracked files. + sig { void } + def self.bust_caches! + @for_file = nil + @memoized_values = nil + Private.bust_caches! + Private.mappers.each(&:bust_caches!) + end +end diff --git a/lib/code_ownership/cli.rb b/lib/code_ownership/cli.rb new file mode 100644 index 0000000..5fcd15b --- /dev/null +++ b/lib/code_ownership/cli.rb @@ -0,0 +1,60 @@ +# typed: true + +require 'optparse' +require 'pathname' + +module CodeOwnership + class Cli + def self.run!(argv) + # Someday we might support other subcommands. When we do that, we can call + # argv.shift to get the first argument and check if it's a given subcommand. + command = argv.shift + if command == 'validate' + validate!(argv) + end + end + + def self.validate!(argv) + options = {} + + parser = OptionParser.new do |opts| + opts.banner = 'Usage: bin/codeownership validate [options]' + + opts.on('--skip-autocorrect', 'Skip automatically correcting any errors, such as the .github/CODEOWNERS file') do + options[:skip_autocorrect] = true + end + + opts.on('-d', '--diff', 'Only run validations with staged files') do + options[:diff] = true + end + + opts.on('-s', '--skip-stage', 'Skips staging the CODEOWNERS file') do + options[:skip_stage] = true + end + + opts.on('--help', 'Shows this prompt') do + puts opts + exit + end + end + args = parser.order!(argv) {} + parser.parse!(args) + + files = if options[:diff] + ENV.fetch('CODEOWNERS_GIT_STAGED_FILES') { `git diff --staged --name-only` }.split("\n").select do |file| + File.exist?(file) + end + else + Private.tracked_files + end + + CodeOwnership.validate!( + files: files, + autocorrect: !options[:skip_autocorrect], + stage_changes: !options[:skip_stage] + ) + end + + private_class_method :validate! + end +end diff --git a/lib/code_ownership/private.rb b/lib/code_ownership/private.rb new file mode 100644 index 0000000..39d69bf --- /dev/null +++ b/lib/code_ownership/private.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# typed: strict + +require 'code_ownership/private/configuration' +require 'code_ownership/private/team_plugins/ownership' +require 'code_ownership/private/team_plugins/github' +require 'code_ownership/private/parse_js_packages' +require 'code_ownership/private/validations/interface' +require 'code_ownership/private/validations/files_have_owners' +require 'code_ownership/private/validations/github_codeowners_up_to_date' +require 'code_ownership/private/validations/files_have_unique_owners' +require 'code_ownership/private/ownership_mappers/interface' +require 'code_ownership/private/ownership_mappers/file_annotations' +require 'code_ownership/private/ownership_mappers/team_globs' +require 'code_ownership/private/ownership_mappers/package_ownership' +require 'code_ownership/private/ownership_mappers/js_package_ownership' + +module CodeOwnership + module Private + extend T::Sig + + sig { returns(Private::Configuration) } + def self.configuration + @configuration ||= T.let(@configuration, T.nilable(Private::Configuration)) + @configuration ||= Private::Configuration.fetch + end + + sig { void } + def self.bust_caches! + @configuration = nil + @tracked_files = nil + @files_by_mapper = nil + end + + sig { params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).void } + def self.validate!(files:, autocorrect: true, stage_changes: true) + validators = [ + Validations::FilesHaveOwners.new, + Validations::FilesHaveUniqueOwners.new, + Validations::GithubCodeownersUpToDate.new, + ] + + errors = validators.flat_map do |validator| + validator.validation_errors( + files: files, + autocorrect: autocorrect, + stage_changes: stage_changes + ) + end + + if errors.any? + errors << 'See https://github.com/bigrails/code_ownership/README.md for more details' + raise InvalidCodeOwnershipConfigurationError.new(errors.join("\n")) # rubocop:disable Style/RaiseArgs + end + end + + sig { returns(T::Array[Private::OwnershipMappers::Interface]) } + def self.mappers + [ + file_annotations_mapper, + Private::OwnershipMappers::TeamGlobs.new, + Private::OwnershipMappers::PackageOwnership.new, + Private::OwnershipMappers::JsPackageOwnership.new, + ] + end + + sig { returns(Private::OwnershipMappers::FileAnnotations) } + def self.file_annotations_mapper + @file_annotations_mapper = T.let(@file_annotations_mapper, T.nilable(Private::OwnershipMappers::FileAnnotations)) + @file_annotations_mapper ||= Private::OwnershipMappers::FileAnnotations.new + end + + # Returns a string version of the relative path to a Rails constant, + # or nil if it can't find something + sig { params(klass: T.nilable(T.any(Class, Module))).returns(T.nilable(String)) } + def self.path_from_klass(klass) + if klass + path = Object.const_source_location(klass.to_s)&.first + (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil + else + nil + end + end + + # + # The output of this function is string pathnames relative to the root. + # + sig { returns(T::Array[String]) } + def self.tracked_files + @tracked_files ||= T.let(@tracked_files, T.nilable(T::Array[String])) + @tracked_files ||= Dir.glob(configuration.owned_globs) + end + + sig { params(team_name: String, location_of_reference: String).returns(Teams::Team) } + def self.find_team!(team_name, location_of_reference) + found_team = Teams.find(team_name) + if found_team.nil? + raise StandardError, "Could not find team with name: `#{team_name}` in #{location_of_reference}. Make sure the team is one of `#{Teams.all.map(&:name).sort}`" + else + found_team + end + end + + sig { params(files: T::Array[String]).returns(T::Hash[String, T::Array[String]]) } + def self.files_by_mapper(files) + @files_by_mapper ||= T.let(@files_by_mapper, T.nilable(T::Hash[String, T::Array[String]])) + @files_by_mapper ||= begin + files_by_mapper = files.map { |file| [file, []] }.to_h + + Private.mappers.each do |mapper| + mapper.map_files_to_owners(files).each do |file, _team| + files_by_mapper[file] ||= [] + T.must(files_by_mapper[file]) << mapper.description + end + end + + files_by_mapper + end + end + end + + private_constant :Private +end diff --git a/lib/code_ownership/private/configuration.rb b/lib/code_ownership/private/configuration.rb new file mode 100644 index 0000000..6fa03a7 --- /dev/null +++ b/lib/code_ownership/private/configuration.rb @@ -0,0 +1,37 @@ +# typed: strict + +module CodeOwnership + module Private + class Configuration < T::Struct + extend T::Sig + DEFAULT_JS_PACKAGE_PATHS = T.let(['**/'], T::Array[String]) + + const :owned_globs, T::Array[String] + const :unowned_globs, T::Array[String] + const :js_package_paths, T::Array[String] + const :skip_codeowners_validation, T::Boolean + + sig { returns(Configuration) } + def self.fetch + config_hash = YAML.load_file('config/code_ownership.yml') + + new( + owned_globs: config_hash.fetch('owned_globs', []), + unowned_globs: config_hash.fetch('unowned_globs', []), + js_package_paths: js_package_paths(config_hash), + skip_codeowners_validation: config_hash.fetch('skip_codeowners_validation', false) + ) + end + + sig { params(config_hash: T::Hash[T.untyped, T.untyped]).returns(T::Array[String]) } + def self.js_package_paths(config_hash) + specified_package_paths = config_hash['js_package_paths'] + if specified_package_paths.nil? + DEFAULT_JS_PACKAGE_PATHS.dup + else + Array(specified_package_paths) + end + end + end + end +end diff --git a/lib/code_ownership/private/ownership_mappers/file_annotations.rb b/lib/code_ownership/private/ownership_mappers/file_annotations.rb new file mode 100644 index 0000000..f8d8708 --- /dev/null +++ b/lib/code_ownership/private/ownership_mappers/file_annotations.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# typed: strict + +module CodeOwnership + module Private + module OwnershipMappers + # Calculate, cache, and return a mapping of file names (relative to the root + # of the repository) to team name. + # + # Example: + # + # { + # 'app/models/company.rb' => Team.find('Setup & Onboarding'), + # ... + # } + class FileAnnotations + extend T::Sig + include Interface + + @@map_files_to_owners = T.let({}, T.nilable(T::Hash[String, T.nilable(::Teams::Team)])) # rubocop:disable Style/ClassVars + + TEAM_PATTERN = T.let(/\A(?:#|\/\/) @team (?.*)\Z/.freeze, Regexp) + + sig do + override.params(file: String). + returns(T.nilable(::Teams::Team)) + end + def map_file_to_owner(file) + file_annotation_based_owner(file) + end + + sig do + override. + params(files: T::Array[String]). + returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def map_files_to_owners(files) + return @@map_files_to_owners if @@map_files_to_owners&.keys && @@map_files_to_owners.keys.count > 0 + + @@map_files_to_owners = files.each_with_object({}) do |filename_relative_to_root, mapping| # rubocop:disable Style/ClassVars + owner = file_annotation_based_owner(filename_relative_to_root) + next unless owner + + mapping[filename_relative_to_root] = owner + end + end + + sig { params(filename: String).returns(T.nilable(Teams::Team)) } + def file_annotation_based_owner(filename) + # If for a directory is named with an ownable extension, we need to skip + # so File.foreach doesn't blow up below. This was needed because Cypress + # screenshots are saved to a folder with the test suite filename. + return if File.directory?(filename) + return unless File.file?(filename) + + # The annotation should be on line 1 but as of this comment + # there's no linter installed to enforce that. We therefore check the + # first line (the Ruby VM makes a single `read(1)` call for 8KB), + # and if the annotation isn't in the first two lines we assume it + # doesn't exist. + + line_1 = File.foreach(filename).first + + return if !line_1 + + begin + team = line_1[TEAM_PATTERN, :team] + rescue ArgumentError => ex + if ex.message.include?('invalid byte sequence') + team = nil + else + raise + end + end + + return unless team + + Private.find_team!( + team, + filename + ) + end + + sig { params(filename: String).void } + def remove_file_annotation!(filename) + if file_annotation_based_owner(filename) + filepath = Pathname.new(filename) + lines = filepath.read.split("\n") + new_lines = lines.select { |line| !line[TEAM_PATTERN] } + # We explicitly add a final new line since splitting by new line when reading the file lines + # ignores new lines at the ends of files + # We also remove leading new lines, since there is after a new line after an annotation + new_file_contents = "#{new_lines.join("\n")}\n".gsub(/\A\n+/, '') + filepath.write(new_file_contents) + end + end + + sig do + override.returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def codeowners_lines_to_owners + @@map_files_to_owners = nil # rubocop:disable Style/ClassVars + map_files_to_owners(Private.tracked_files) + end + + sig { override.returns(String) } + def description + 'Annotations at the top of file' + end + + sig { override.void } + def bust_caches! + @@map_files_to_owners = {} # rubocop:disable Style/ClassVars + end + end + end + end +end diff --git a/lib/code_ownership/private/ownership_mappers/interface.rb b/lib/code_ownership/private/ownership_mappers/interface.rb new file mode 100644 index 0000000..1debcce --- /dev/null +++ b/lib/code_ownership/private/ownership_mappers/interface.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# typed: strict + +module CodeOwnership + module Private + module OwnershipMappers + module Interface + extend T::Sig + extend T::Helpers + + interface! + + # + # This should be fast when run with ONE file + # + sig do + abstract.params(file: String). + returns(T.nilable(::Teams::Team)) + end + def map_file_to_owner(file) + end + + # + # This should be fast when run with MANY files + # + sig do + abstract.params(files: T::Array[String]). + returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def map_files_to_owners(files) + end + + sig do + abstract.returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def codeowners_lines_to_owners + end + + sig { abstract.returns(String) } + def description + end + + sig { abstract.void } + def bust_caches! + end + end + end + end +end diff --git a/lib/code_ownership/private/ownership_mappers/js_package_ownership.rb b/lib/code_ownership/private/ownership_mappers/js_package_ownership.rb new file mode 100644 index 0000000..404748b --- /dev/null +++ b/lib/code_ownership/private/ownership_mappers/js_package_ownership.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +# typed: true + +module CodeOwnership + module Private + module OwnershipMappers + class JsPackageOwnership + extend T::Sig + include Interface + + @@package_json_cache = T.let({}, T::Hash[String, T.nilable(ParseJsPackages::Package)]) # rubocop:disable Style/ClassVars + + sig do + override.params(file: String). + returns(T.nilable(::Teams::Team)) + end + def map_file_to_owner(file) + package = map_file_to_relevant_package(file) + + return nil if package.nil? + + owner_for_package(package) + end + + sig do + override. + params(files: T::Array[String]). + returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def map_files_to_owners(files) # rubocop:disable Lint/UnusedMethodArgument + ParseJsPackages.all.each_with_object({}) do |package, res| + owner = owner_for_package(package) + next if owner.nil? + + glob = package.directory.join('**/**').to_s + Dir.glob(glob).each do |path| + res[path] = owner + end + end + end + + # + # Package ownership ignores the passed in files when generating code owners lines. + # This is because Package ownership knows that the fastest way to find code owners for package based ownership + # 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 + # In theory this means that we may generate code owners lines that cover files that are not in the passed in argument, + # but in practice this is not of consequence because in reality we never really want to generate code owners for only a + # subset of files, but rather we want code ownership for all files. + # + sig do + override.returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def codeowners_lines_to_owners + ParseJsPackages.all.each_with_object({}) do |package, res| + owner = owner_for_package(package) + next if owner.nil? + + res[package.directory.join('**/**').to_s] = owner + end + end + + sig { override.returns(String) } + def description + 'Owner metadata key in package.json' + end + + sig { params(package: ParseJsPackages::Package).returns(T.nilable(Teams::Team)) } + def owner_for_package(package) + raw_owner_value = package.metadata['owner'] + return nil if !raw_owner_value + + Private.find_team!( + raw_owner_value, + package.name + ) + end + + sig { override.void } + def bust_caches! + @@package_json_cache = {} # rubocop:disable Style/ClassVars + end + + private + + # takes a file and finds the relevant `package.json` file by walking up the directory + # structure. Example, given `packages/a/b/c.rb`, this looks for `packages/a/b/package.json`, `packages/a/package.json`, + # `packages/package.json`, and `package.json` in that order, stopping at the first file to actually exist. + # We do additional caching so that we don't have to check for file existence every time + sig { params(file: String).returns(T.nilable(ParseJsPackages::Package)) } + def map_file_to_relevant_package(file) + file_path = Pathname.new(file) + path_components = file_path.each_filename.to_a.map { |path| Pathname.new(path) } + + (path_components.length - 1).downto(0).each do |i| + potential_relative_path_name = T.must(path_components[0...i]).reduce(Pathname.new('')) { |built_path, path| built_path.join(path) } + potential_package_json_path = potential_relative_path_name. + join(ParseJsPackages::PACKAGE_JSON_NAME) + + potential_package_json_string = potential_package_json_path.to_s + + package = nil + if @@package_json_cache.key?(potential_package_json_string) + package = @@package_json_cache[potential_package_json_string] + elsif potential_package_json_path.exist? + package = ParseJsPackages::Package.from(potential_package_json_path) + + @@package_json_cache[potential_package_json_string] = package + else + @@package_json_cache[potential_package_json_string] = nil + end + + return package unless package.nil? + end + + nil + end + end + end + end +end diff --git a/lib/code_ownership/private/ownership_mappers/package_ownership.rb b/lib/code_ownership/private/ownership_mappers/package_ownership.rb new file mode 100644 index 0000000..33b41b7 --- /dev/null +++ b/lib/code_ownership/private/ownership_mappers/package_ownership.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +# typed: true + +module CodeOwnership + module Private + module OwnershipMappers + class PackageOwnership + extend T::Sig + include Interface + + @@package_yml_cache = T.let({}, T::Hash[String, T.nilable(ParsePackwerk::Package)]) # rubocop:disable Style/ClassVars + + sig do + override.params(file: String). + returns(T.nilable(::Teams::Team)) + end + def map_file_to_owner(file) + package = map_file_to_relevant_package(file) + + return nil if package.nil? + + owner_for_package(package) + end + + sig do + override. + params(files: T::Array[String]). + returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def map_files_to_owners(files) # rubocop:disable Lint/UnusedMethodArgument + ParsePackwerk.all.each_with_object({}) do |package, res| + owner = owner_for_package(package) + next if owner.nil? + + glob = package.directory.join('**/**').to_s + Dir.glob(glob).each do |path| + res[path] = owner + end + end + end + + # + # Package ownership ignores the passed in files when generating code owners lines. + # This is because Package ownership knows that the fastest way to find code owners for package based ownership + # 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 + # In theory this means that we may generate code owners lines that cover files that are not in the passed in argument, + # but in practice this is not of consequence because in reality we never really want to generate code owners for only a + # subset of files, but rather we want code ownership for all files. + # + sig do + override.returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def codeowners_lines_to_owners + ParsePackwerk.all.each_with_object({}) do |package, res| + owner = owner_for_package(package) + next if owner.nil? + + res[package.directory.join('**/**').to_s] = owner + end + end + + sig { override.returns(String) } + def description + 'Owner metadata key in package.yml' + end + + sig { params(package: ParsePackwerk::Package).returns(T.nilable(Teams::Team)) } + def owner_for_package(package) + raw_owner_value = package.metadata['owner'] + return nil if !raw_owner_value + + Private.find_team!( + raw_owner_value, + package.yml.to_s + ) + end + + sig { override.void } + def bust_caches! + @@package_yml_cache = {} # rubocop:disable Style/ClassVars + end + + private + + # takes a file and finds the relevant `package.yml` file by walking up the directory + # structure. Example, given `packs/a/b/c.rb`, this looks for `packs/a/b/package.yml`, `packs/a/package.yml`, + # `packs/package.yml`, and `package.yml` in that order, stopping at the first file to actually exist. + # We do additional caching so that we don't have to check for file existence every time + sig { params(file: String).returns(T.nilable(ParsePackwerk::Package)) } + def map_file_to_relevant_package(file) + file_path = Pathname.new(file) + path_components = file_path.each_filename.to_a.map { |path| Pathname.new(path) } + + (path_components.length - 1).downto(0).each do |i| + potential_relative_path_name = T.must(path_components[0...i]).reduce(Pathname.new('')) { |built_path, path| built_path.join(path) } + potential_package_yml_path = potential_relative_path_name. + join(ParsePackwerk::PACKAGE_YML_NAME) + + potential_package_yml_string = potential_package_yml_path.to_s + + package = nil + if @@package_yml_cache.key?(potential_package_yml_string) + package = @@package_yml_cache[potential_package_yml_string] + elsif potential_package_yml_path.exist? + package = ParsePackwerk::Package.from(potential_package_yml_path) + + @@package_yml_cache[potential_package_yml_string] = package + else + @@package_yml_cache[potential_package_yml_string] = nil + end + + return package unless package.nil? + end + + nil + end + end + end + end +end diff --git a/lib/code_ownership/private/ownership_mappers/team_globs.rb b/lib/code_ownership/private/ownership_mappers/team_globs.rb new file mode 100644 index 0000000..ea4b42e --- /dev/null +++ b/lib/code_ownership/private/ownership_mappers/team_globs.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# typed: true + +module CodeOwnership + module Private + module OwnershipMappers + class TeamGlobs + extend T::Sig + include Interface + + @@map_files_to_owners = T.let(@map_files_to_owners, T.nilable(T::Hash[String, T.nilable(::Teams::Team)])) # rubocop:disable Style/ClassVars + @@map_files_to_owners = {} # rubocop:disable Style/ClassVars + @@codeowners_lines_to_owners = T.let(@codeowners_lines_to_owners, T.nilable(T::Hash[String, T.nilable(::Teams::Team)])) # rubocop:disable Style/ClassVars + @@codeowners_lines_to_owners = {} # rubocop:disable Style/ClassVars + + sig do + override. + params(files: T::Array[String]). + returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def map_files_to_owners(files) # rubocop:disable Lint/UnusedMethodArgument + return @@map_files_to_owners if @@map_files_to_owners&.keys && @@map_files_to_owners.keys.count > 0 + + @@map_files_to_owners = Teams.all.each_with_object({}) do |team, map| # rubocop:disable Style/ClassVars + TeamPlugins::Ownership.for(team).owned_globs.each do |glob| + Dir.glob(glob).each do |filename| + map[filename] = team + end + end + end + end + + sig do + override.params(file: String). + returns(T.nilable(::Teams::Team)) + end + def map_file_to_owner(file) + map_files_to_owners([file])[file] + end + + sig do + override.returns(T::Hash[String, T.nilable(::Teams::Team)]) + end + def codeowners_lines_to_owners + return @@codeowners_lines_to_owners if @@codeowners_lines_to_owners&.keys && @@codeowners_lines_to_owners.keys.count > 0 + + @@codeowners_lines_to_owners = Teams.all.each_with_object({}) do |team, map| # rubocop:disable Style/ClassVars + TeamPlugins::Ownership.for(team).owned_globs.each do |owned_glob| + map[owned_glob] = team + end + end + end + + sig { override.void } + def bust_caches! + @@codeowners_lines_to_owners = {} # rubocop:disable Style/ClassVars + @@map_files_to_owners = {} # rubocop:disable Style/ClassVars + end + + sig { override.returns(String) } + def description + 'Team-specific owned globs' + end + end + end + end +end diff --git a/lib/code_ownership/private/parse_js_packages.rb b/lib/code_ownership/private/parse_js_packages.rb new file mode 100644 index 0000000..1ef30b8 --- /dev/null +++ b/lib/code_ownership/private/parse_js_packages.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# typed: true + +module CodeOwnership + module Private + # Modeled off of ParsePackwerk + module ParseJsPackages + extend T::Sig + + ROOT_PACKAGE_NAME = 'root' + PACKAGE_JSON_NAME = T.let('package.json', String) + METADATA = 'metadata' + + class Package < T::Struct + extend T::Sig + + const :name, String + const :metadata, T::Hash[String, T.untyped] + + sig { params(pathname: Pathname).returns(Package) } + def self.from(pathname) + package_loaded_json = JSON.parse(pathname.read) + + package_name = if pathname.dirname == Pathname.new('.') + ROOT_PACKAGE_NAME + else + pathname.dirname.cleanpath.to_s + end + + new( + name: package_name, + metadata: package_loaded_json[METADATA] || {} + ) + end + + sig { returns(Pathname) } + def directory + root_pathname = Pathname.new('.') + name == ROOT_PACKAGE_NAME ? root_pathname.cleanpath : root_pathname.join(name).cleanpath + end + end + + sig do + returns(T::Array[Package]) + end + def self.all + package_glob_patterns = Private.configuration.js_package_paths.map do |pathspec| + File.join(pathspec, PACKAGE_JSON_NAME) + end + + # The T.unsafe is because the upstream RBI is wrong for Pathname.glob + T.unsafe(Pathname).glob(package_glob_patterns).map(&:cleanpath).map do |path| + Package.from(path) + end + end + end + end +end diff --git a/lib/code_ownership/private/team_plugins/github.rb b/lib/code_ownership/private/team_plugins/github.rb new file mode 100644 index 0000000..0427d4d --- /dev/null +++ b/lib/code_ownership/private/team_plugins/github.rb @@ -0,0 +1,24 @@ +# typed: true + +module CodeOwnership + module Private + module TeamPlugins + class Github < Teams::Plugin + extend T::Sig + extend T::Helpers + + GithubStruct = Struct.new(:team, :do_not_add_to_codeowners_file) + + sig { returns(GithubStruct) } + def github + raw_github = @team.raw_hash['github'] || {} + + GithubStruct.new( + raw_github['team'], + raw_github['do_not_add_to_codeowners_file'] || false + ) + end + end + end + end +end diff --git a/lib/code_ownership/private/team_plugins/ownership.rb b/lib/code_ownership/private/team_plugins/ownership.rb new file mode 100644 index 0000000..113cd57 --- /dev/null +++ b/lib/code_ownership/private/team_plugins/ownership.rb @@ -0,0 +1,17 @@ +# typed: true + +module CodeOwnership + module Private + module TeamPlugins + class Ownership < Teams::Plugin + extend T::Sig + extend T::Helpers + + sig { returns(T::Array[String]) } + def owned_globs + @team.raw_hash['owned_globs'] || [] + end + end + end + end +end diff --git a/lib/code_ownership/private/validations/files_have_owners.rb b/lib/code_ownership/private/validations/files_have_owners.rb new file mode 100644 index 0000000..67f8d77 --- /dev/null +++ b/lib/code_ownership/private/validations/files_have_owners.rb @@ -0,0 +1,34 @@ +# typed: strict + +module CodeOwnership + module Private + module Validations + class FilesHaveOwners + extend T::Sig + extend T::Helpers + include Interface + + sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } + def validation_errors(files:, autocorrect: true, stage_changes: true) + allow_list = Dir.glob(Private.configuration.unowned_globs) + files_by_mapper = Private.files_by_mapper(files) + files_not_mapped_at_all = files_by_mapper.select { |_file, mapper_descriptions| mapper_descriptions.count == 0 }.keys + + files_without_owners = files_not_mapped_at_all - allow_list + + errors = T.let([], T::Array[String]) + + if files_without_owners.any? + errors << <<~MSG + Some files are missing ownership: + + #{files_without_owners.map { |file| "- #{file}" }.join("\n")} + MSG + end + + errors + end + end + end + end +end diff --git a/lib/code_ownership/private/validations/files_have_unique_owners.rb b/lib/code_ownership/private/validations/files_have_unique_owners.rb new file mode 100644 index 0000000..46f616e --- /dev/null +++ b/lib/code_ownership/private/validations/files_have_unique_owners.rb @@ -0,0 +1,32 @@ +# typed: strict + +module CodeOwnership + module Private + module Validations + class FilesHaveUniqueOwners + extend T::Sig + extend T::Helpers + include Interface + + sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } + def validation_errors(files:, autocorrect: true, stage_changes: true) + files_by_mapper = Private.files_by_mapper(files) + + files_mapped_by_multiple_mappers = files_by_mapper.select { |_file, mapper_descriptions| mapper_descriptions.count > 1 }.to_h + + errors = T.let([], T::Array[String]) + + if files_mapped_by_multiple_mappers.any? + errors << <<~MSG + Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways. + + #{files_mapped_by_multiple_mappers.map { |file, descriptions| "- #{file} (#{descriptions.join(', ')})" }.join("\n")} + MSG + end + + errors + end + end + end + end +end diff --git a/lib/code_ownership/private/validations/github_codeowners_up_to_date.rb b/lib/code_ownership/private/validations/github_codeowners_up_to_date.rb new file mode 100644 index 0000000..417a09f --- /dev/null +++ b/lib/code_ownership/private/validations/github_codeowners_up_to_date.rb @@ -0,0 +1,85 @@ +# typed: strict + +module CodeOwnership + module Private + module Validations + class GithubCodeownersUpToDate + extend T::Sig + extend T::Helpers + include Interface + + sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } + def validation_errors(files:, autocorrect: true, stage_changes: true) + return [] if Private.configuration.skip_codeowners_validation + + codeowners_filepath = Pathname.pwd.join('.github/CODEOWNERS') + FileUtils.mkdir_p(codeowners_filepath.dirname) if !codeowners_filepath.dirname.exist? + + header = <<~HEADER + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + HEADER + + contents = [ + header, + *codeowners_file_lines, + nil, # For end-of-file newline + ].join("\n") + + codeowners_up_to_date = codeowners_filepath.exist? && codeowners_filepath.read == contents + + errors = T.let([], T::Array[String]) + + if !codeowners_up_to_date + if autocorrect + codeowners_filepath.write(contents) + if stage_changes + `git add #{codeowners_filepath}` + end + else + errors << "CODEOWNERS out of date. Ensure pre-commit hook is set up correctly and used. You can also run bin/codeownership validate to update the CODEOWNERS file\n" + end + end + + errors + end + + private + + # Generate the contents of a CODEOWNERS file that GitHub can use to + # automatically assign reviewers + # https://help.github.com/articles/about-codeowners/ + sig { returns(T::Array[String]) } + def codeowners_file_lines + github_team_map = Teams.all.each_with_object({}) do |team, map| + team_github = TeamPlugins::Github.for(team).github + next if team_github.do_not_add_to_codeowners_file + + map[team.name] = team_github.team + end + + Private.mappers.flat_map do |mapper| + codeowners_lines = mapper.codeowners_lines_to_owners.filter_map do |line, team| + team_mapping = github_team_map[team&.name] + next unless team_mapping + + "/#{line} #{team_mapping}" + end + next [] if codeowners_lines.empty? + + [ + '', + "# #{mapper.description}", + *codeowners_lines.sort, + ] + end + end + end + end + end +end diff --git a/lib/code_ownership/private/validations/interface.rb b/lib/code_ownership/private/validations/interface.rb new file mode 100644 index 0000000..838c9dc --- /dev/null +++ b/lib/code_ownership/private/validations/interface.rb @@ -0,0 +1,18 @@ +# typed: strict + +module CodeOwnership + module Private + module Validations + module Interface + extend T::Sig + extend T::Helpers + + interface! + + sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) } + def validation_errors(files:, autocorrect: true, stage_changes: true) + end + end + end + end +end diff --git a/sorbet/config b/sorbet/config new file mode 100644 index 0000000..23fef8c --- /dev/null +++ b/sorbet/config @@ -0,0 +1,4 @@ +--dir +. +--ignore=/spec +--enable-experimental-requires-ancestor diff --git a/sorbet/rbi/gems/bigrails-teams@0.1.0.rbi b/sorbet/rbi/gems/bigrails-teams@0.1.0.rbi new file mode 100644 index 0000000..f40e574 --- /dev/null +++ b/sorbet/rbi/gems/bigrails-teams@0.1.0.rbi @@ -0,0 +1,120 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `bigrails-teams` gem. +# Please instead update this file by running `bin/tapioca gem bigrails-teams`. + +module Teams + class << self + sig { returns(T::Array[::Teams::Team]) } + def all; end + + sig { void } + def bust_caches!; end + + sig { params(name: ::String).returns(T.nilable(::Teams::Team)) } + def find(name); end + + sig { params(dir: ::String).returns(T::Array[::Teams::Team]) } + def for_directory(dir); end + + sig { params(string: ::String).returns(::String) } + def tag_value_for(string); end + + sig { params(teams: T::Array[::Teams::Team]).returns(T::Array[::String]) } + def validation_errors(teams); end + end +end + +class Teams::IncorrectPublicApiUsageError < ::StandardError; end + +class Teams::Plugin + abstract! + + sig { params(team: ::Teams::Team).void } + def initialize(team); end + + class << self + sig { returns(T::Array[T.class_of(Teams::Plugin)]) } + def all_plugins; end + + sig { params(team: ::Teams::Team).returns(T.attached_class) } + def for(team); end + + sig { params(base: T.untyped).void } + def inherited(base); end + + sig { params(team: ::Teams::Team, key: ::String).returns(::String) } + def missing_key_error_message(team, key); end + + sig { params(teams: T::Array[::Teams::Team]).returns(T::Array[::String]) } + def validation_errors(teams); end + + private + + sig { params(team: ::Teams::Team).returns(T.attached_class) } + def register_team(team); end + + sig { returns(T::Hash[T.nilable(::String), T::Hash[::Class, ::Teams::Plugin]]) } + def registry; end + end +end + +module Teams::Plugins; end + +class Teams::Plugins::Identity < ::Teams::Plugin + sig { returns(::Teams::Plugins::Identity::IdentityStruct) } + def identity; end + + class << self + sig { override.params(teams: T::Array[::Teams::Team]).returns(T::Array[::String]) } + def validation_errors(teams); end + end +end + +class Teams::Plugins::Identity::IdentityStruct < ::Struct + def name; end + def name=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def members; end + def new(*_arg0); end + end +end + +class Teams::Team + sig { params(config_yml: T.nilable(::String), raw_hash: T::Hash[T.untyped, T.untyped]).void } + def initialize(config_yml:, raw_hash:); end + + sig { params(other: ::Object).returns(T::Boolean) } + def ==(other); end + + sig { returns(T.nilable(::String)) } + def config_yml; end + + def eql?(*args, &blk); end + + sig { returns(::Integer) } + def hash; end + + sig { returns(::String) } + def name; end + + sig { returns(T::Hash[T.untyped, T.untyped]) } + def raw_hash; end + + sig { returns(::String) } + def to_tag; end + + class << self + sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(::Teams::Team) } + def from_hash(raw_hash); end + + sig { params(config_yml: ::String).returns(::Teams::Team) } + def from_yml(config_yml); end + end +end + +Teams::UNKNOWN_TEAM_STRING = T.let(T.unsafe(nil), String) diff --git a/sorbet/rbi/gems/parse_packwerk@0.7.0.rbi b/sorbet/rbi/gems/parse_packwerk@0.7.0.rbi new file mode 100644 index 0000000..d8fc5a9 --- /dev/null +++ b/sorbet/rbi/gems/parse_packwerk@0.7.0.rbi @@ -0,0 +1,111 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for types exported from the `parse_packwerk` gem. +# Please instead update this file by running `bin/tapioca gem parse_packwerk`. + +module ParsePackwerk + class << self + sig { params(package_yml_pathnames: T.nilable(T::Array[::Pathname])).returns(T::Array[::ParsePackwerk::Package]) } + def all(package_yml_pathnames: T.unsafe(nil)); end + + sig { params(name: ::String).returns(T.nilable(::ParsePackwerk::Package)) } + def find(name); end + + sig { params(package: ::ParsePackwerk::Package).void } + def write_package_yml!(package); end + + sig { returns(::ParsePackwerk::Configuration) } + def yml; end + + private + + sig { returns(T::Hash[::String, ::ParsePackwerk::Package]) } + def packages_by_name; end + end +end + +class ParsePackwerk::Configuration < ::T::Struct + const :exclude, T::Array[::String] + + class << self + def inherited(s); end + end +end + +ParsePackwerk::DEPENDENCIES = T.let(T.unsafe(nil), String) +ParsePackwerk::DEPRECATED_REFERENCES_YML_NAME = T.let(T.unsafe(nil), String) + +class ParsePackwerk::DeprecatedReferences < ::T::Struct + const :pathname, ::Pathname + const :violations, T::Array[::ParsePackwerk::Violation] + + class << self + sig { params(package: ::ParsePackwerk::Package).returns(::ParsePackwerk::DeprecatedReferences) } + def for(package); end + + sig { params(pathname: ::Pathname).returns(::ParsePackwerk::DeprecatedReferences) } + def from(pathname); end + + def inherited(s); end + end +end + +ParsePackwerk::ENFORCE_DEPENDENCIES = T.let(T.unsafe(nil), String) +ParsePackwerk::ENFORCE_PRIVACY = T.let(T.unsafe(nil), String) +ParsePackwerk::METADATA = T.let(T.unsafe(nil), String) +ParsePackwerk::MetadataYmlType = T.type_alias { T::Hash[T.untyped, T.untyped] } + +class ParsePackwerk::MissingConfiguration < ::StandardError + sig { params(packwerk_file_name: ::Pathname).void } + def initialize(packwerk_file_name); end +end + +ParsePackwerk::PACKAGE_YML_NAME = T.let(T.unsafe(nil), String) +ParsePackwerk::PACKWERK_YML_NAME = T.let(T.unsafe(nil), String) + +class ParsePackwerk::Package < ::T::Struct + const :dependencies, T::Array[::String] + const :enforce_dependencies, T::Boolean + const :enforce_privacy, T::Boolean + const :metadata, T::Hash[T.untyped, T.untyped] + const :name, ::String + + sig { returns(::Pathname) } + def directory; end + + sig { returns(T::Boolean) } + def enforces_dependencies?; end + + sig { returns(T::Boolean) } + def enforces_privacy?; end + + sig { returns(::Pathname) } + def yml; end + + class << self + sig { params(pathname: ::Pathname).returns(::ParsePackwerk::Package) } + def from(pathname); end + + def inherited(s); end + end +end + +ParsePackwerk::ROOT_PACKAGE_NAME = T.let(T.unsafe(nil), String) + +class ParsePackwerk::Violation < ::T::Struct + const :class_name, ::String + const :files, T::Array[::String] + const :to_package_name, ::String + const :type, ::String + + sig { returns(T::Boolean) } + def dependency?; end + + sig { returns(T::Boolean) } + def privacy?; end + + class << self + def inherited(s); end + end +end diff --git a/sorbet/rbi/todo.rbi b/sorbet/rbi/todo.rbi new file mode 100644 index 0000000..e4c81e8 --- /dev/null +++ b/sorbet/rbi/todo.rbi @@ -0,0 +1,6 @@ +# This file is autogenerated. Do not edit it by hand. Regenerate it with: +# srb rbi todo + +# typed: strong +module ::RSpec; end +module ::SequoiaTree; end diff --git a/spec/lib/code_ownership/cli_spec.rb b/spec/lib/code_ownership/cli_spec.rb new file mode 100644 index 0000000..6cea67b --- /dev/null +++ b/spec/lib/code_ownership/cli_spec.rb @@ -0,0 +1,26 @@ +RSpec.describe CodeOwnership::Cli do + subject { CodeOwnership::Cli.run!(argv) } + + let(:argv) { ['validate'] } + + before do + write_file('config/code_ownership.yml', <<~YML) + owned_globs: + - 'app/**/*.rb' + YML + + write_file('app/services/my_file.rb') + write_file('frontend/javascripts/my_file.jsx') + end + + context 'when run without arguments' do + it 'runs validations with the right defaults' do + expect(CodeOwnership).to receive(:validate!) do |args| # rubocop:disable RSpec/MessageSpies + expect(args[:autocorrect]).to eq true + expect(args[:stage_changes]).to eq true + expect(args[:files]).to match_array(['app/services/my_file.rb']) + end + subject + end + end +end diff --git a/spec/lib/code_ownership_spec.rb b/spec/lib/code_ownership_spec.rb new file mode 100644 index 0000000..363f743 --- /dev/null +++ b/spec/lib/code_ownership_spec.rb @@ -0,0 +1,725 @@ +RSpec.describe CodeOwnership do + describe '.validate!' do + let(:codeowners_validation) { CodeOwnership.const_get(:Private)::Validations::GithubCodeownersUpToDate } + + describe 'files are required to have ownership validation' do + context 'input files are not part of configured owned_globs' do + before do + write_file('Gemfile', <<~CONTENTS) + CONTENTS + + create_minimal_configuration + end + + it 'raises errors due to being misconfigured' do + expect { CodeOwnership.validate!(files: ['Gemfile']) }.to_not raise_error + end + end + + context 'a file in owned_globs does not have an owner' do + before do + write_file('app/missing_ownership.rb', <<~CONTENTS) + CONTENTS + end + + context 'the file is not in unowned_globs' do + before do + create_minimal_configuration + end + + it 'lets the user know the file must have ownership' do + expect { CodeOwnership.validate! }.to raise_error do |e| + expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + Some files are missing ownership: + + - app/missing_ownership.rb + + See https://github.com/bigrails/code_ownership/README.md for more details + EXPECTED + end + end + + context 'the input files do not include the file missing ownership' do + it 'ignores the file missing ownership' do + expect { CodeOwnership.validate!(files: ['app/some_other_file.rb']) }.to_not raise_error + end + end + end + + context 'that file is in unowned_globs' do + before do + write_file('config/code_ownership.yml', <<~YML) + owned_globs: + - 'app/**/*.rb' + unowned_globs: + - app/missing_ownership.rb + YML + end + + it 'lets the user know the file must have ownership' do + expect { CodeOwnership.validate! }.to_not raise_error + end + end + end + end + + describe 'files can only be mapped in one way validation' do + context 'a file in owned_globs has ownership defined in multiple ways' do + before do + write_file('config/code_ownership.yml', <<~YML) + owned_globs: + - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}' + YML + + write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) + # @team Bar + CONTENTS + + write_file('frontend/javascripts/packages/my_package/owned_file.jsx', <<~CONTENTS) + // @team Bar + CONTENTS + + write_file('frontend/javascripts/packages/my_package/package.json', <<~CONTENTS) + { + "name": "@gusto/my_package", + "metadata": { + "owner": "Bar" + } + } + CONTENTS + + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + owned_globs: + - packs/** + - frontend/javascripts/packages//** + CONTENTS + + write_file('packs/my_pack/package.yml', <<~CONTENTS) + enforce_dependency: true + enforce_privacy: true + metadata: + owner: Bar + CONTENTS + end + + it 'lets the user know that each file can only have ownership defined in one way' do + expect(CodeOwnership.for_file('app/missing_ownership.rb')).to eq nil + + expect { CodeOwnership.validate! }.to raise_error do |e| + expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways. + + - frontend/javascripts/packages/my_package/owned_file.jsx (Annotations at the top of file, Owner metadata key in package.json) + - packs/my_pack/owned_file.rb (Annotations at the top of file, Owner metadata key in package.yml) + + See https://github.com/bigrails/code_ownership/README.md for more details + EXPECTED + end + end + + context 'the input files do not include the file owned in multiple ways' do + it 'ignores the file with multiple ownership' do + expect { CodeOwnership.validate!(files: ['app/some_other_file.rb']) }.to_not raise_error + end + end + end + end + + describe '.github/CODEOWNERS validation' do + context 'run with autocorrect' do + before do + create_minimal_configuration + end + + context 'in an empty application' do + it 'automatically regenerates the codeowners file' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{Pathname.pwd.join('.github/CODEOWNERS')}") # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate! }.to_not raise_error + expect(Pathname.new('.github/CODEOWNERS').read).to eq <<~EXPECTED + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + + EXPECTED + end + end + + context 'in an non-empty application' do + before { create_non_empty_application } + + it 'automatically regenerates the codeowners file' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{Pathname.pwd.join('.github/CODEOWNERS')}") # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate! }.to_not raise_error + expect(Pathname.new('.github/CODEOWNERS').read).to eq <<~EXPECTED + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + + + # Annotations at the top of file + /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team + /packs/my_pack/owned_file.rb @MyOrg/bar-team + + # Team-specific owned globs + /app/services/bar_stuff/** @MyOrg/bar-team + /frontend/javascripts/bar_stuff/** @MyOrg/bar-team + + # Owner metadata key in package.yml + /packs/my_other_package/**/** @MyOrg/bar-team + + # Owner metadata key in package.json + /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team + EXPECTED + end + + context 'the user has passed in specific input files into the validate method' do + it 'still automatically regenerates the codeowners file, since we look at all files when regenerating CODEOWNERS' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to receive(:`).with("git add #{Pathname.pwd.join('.github/CODEOWNERS')}") # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate! }.to_not raise_error + expect(Pathname.new('.github/CODEOWNERS').read).to eq <<~EXPECTED + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + + + # Annotations at the top of file + /frontend/javascripts/packages/my_package/owned_file.jsx @MyOrg/bar-team + /packs/my_pack/owned_file.rb @MyOrg/bar-team + + # Team-specific owned globs + /app/services/bar_stuff/** @MyOrg/bar-team + /frontend/javascripts/bar_stuff/** @MyOrg/bar-team + + # Owner metadata key in package.yml + /packs/my_other_package/**/** @MyOrg/bar-team + + # Owner metadata key in package.json + /frontend/javascripts/packages/my_other_package/**/** @MyOrg/bar-team + EXPECTED + end + end + + context 'team does not have a github team listed' do + before do + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + owned_globs: + - app/services/bar_stuff/** + - frontend/javascripts/bar_stuff/** + CONTENTS + end + + it 'does not include the team in the output' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect { CodeOwnership.validate! }.to_not raise_error + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect(Pathname.new('.github/CODEOWNERS').read).to eq <<~EXPECTED + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + + EXPECTED + end + end + + context 'team has chosen to not be added to CODEOWNERS' do + before do + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + github: + team: '@MyOrg/bar-team' + do_not_add_to_codeowners_file: true + owned_globs: + - app/services/bar_stuff/** + - frontend/javascripts/bar_stuff/** + CONTENTS + end + + it 'does not include the team in the output' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect { CodeOwnership.validate! }.to_not raise_error + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect(Pathname.new('.github/CODEOWNERS').read).to eq <<~EXPECTED + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + + EXPECTED + end + end + end + + context 'run without staging changes' do + before do + create_minimal_configuration + end + + it 'does not stage the changes to the codeowners file' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate!(stage_changes: false) }.to_not raise_error + expect(Pathname.new('.github/CODEOWNERS').read).to eq <<~EXPECTED + # STOP! - DO NOT EDIT THIS FILE MANUALLY + # This file was automatically generated by "bin/codeownership validate". + # + # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub + # teams. This is useful when developers create Pull Requests since the + # code/file owner is notified. Reference GitHub docs for more details: + # https://help.github.com/en/articles/about-code-owners + + EXPECTED + end + end + end + + context 'run without autocorrect' do + before do + create_minimal_configuration + end + + context 'in an empty application' do + it 'automatically regenerates the codeowners file' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| + expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + CODEOWNERS out of date. Ensure pre-commit hook is set up correctly and used. You can also run bin/codeownership validate to update the CODEOWNERS file + + See https://github.com/bigrails/code_ownership/README.md for more details + EXPECTED + end + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + end + end + + context 'in an non-empty application' do + before { create_non_empty_application } + + it 'automatically regenerates the codeowners file' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| + expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + CODEOWNERS out of date. Ensure pre-commit hook is set up correctly and used. You can also run bin/codeownership validate to update the CODEOWNERS file + + See https://github.com/bigrails/code_ownership/README.md for more details + EXPECTED + end + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + end + + context 'team does not have a github team listed' do + before do + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + owned_globs: + - app/services/bar_stuff/** + - frontend/javascripts/bar_stuff/** + CONTENTS + end + + it 'does not include the team in the output' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| + expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + CODEOWNERS out of date. Ensure pre-commit hook is set up correctly and used. You can also run bin/codeownership validate to update the CODEOWNERS file + + See https://github.com/bigrails/code_ownership/README.md for more details + EXPECTED + end + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + end + end + + context 'team has chosen to not be added to CODEOWNERS' do + before do + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + github: + team: '@MyOrg/bar-team' + do_not_add_to_codeowners_file: true + owned_globs: + - app/services/bar_stuff/** + - frontend/javascripts/bar_stuff/** + CONTENTS + end + + it 'does not include the team in the output' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate!(autocorrect: false) }.to raise_error do |e| + expect(e).to be_a CodeOwnership::InvalidCodeOwnershipConfigurationError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + CODEOWNERS out of date. Ensure pre-commit hook is set up correctly and used. You can also run bin/codeownership validate to update the CODEOWNERS file + + See https://github.com/bigrails/code_ownership/README.md for more details + EXPECTED + end + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + end + end + end + end + + context 'code_ownership.yml has skip_codeowners_validation set' do + before do + write_file('config/code_ownership.yml', <<~YML) + owned_globs: + - app/**/*.rb + skip_codeowners_validation: true + YML + end + + it 'skips validating the codeowners file' do + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + expect_any_instance_of(codeowners_validation).to_not receive(:`) # rubocop:disable RSpec/AnyInstance + expect { CodeOwnership.validate!(autocorrect: false) }.to_not raise_error + expect(Pathname.new('.github/CODEOWNERS')).to_not exist + end + end + end + + describe 'teams must exist validation' do + before do + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + CONTENTS + + create_minimal_configuration + end + + context 'invalid team in a file annotation' do + before do + write_file('app/some_file.rb', <<~CONTENTS) + # @team Foo + CONTENTS + end + + it 'lets the user know the team cannot be found in the file' do + expect { CodeOwnership.validate! }.to raise_error do |e| + expect(e).to be_a StandardError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + Could not find team with name: `Foo` in app/some_file.rb. Make sure the team is one of `["Bar"]` + EXPECTED + end + end + end + + context 'invalid team in a package.yml' do + before do + write_file('packs/my_pack/package.yml', <<~CONTENTS) + metadata: + owner: Foo + CONTENTS + end + + it 'lets the user know the team cannot be found in the package.yml' do + expect { CodeOwnership.validate! }.to raise_error do |e| + expect(e).to be_a StandardError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + Could not find team with name: `Foo` in packs/my_pack/package.yml. Make sure the team is one of `["Bar"]` + EXPECTED + end + end + end + + context 'invalid team in a package.json' do + before do + write_file('frontend/javascripts/my_package/package.json', <<~CONTENTS) + { + "metadata": { + "owner": "Foo" + } + } + CONTENTS + end + + it 'lets the user know the team cannot be found in the package.json' do + expect { CodeOwnership.validate! }.to raise_error do |e| + expect(e).to be_a StandardError + puts e.message + expect(e.message).to eq <<~EXPECTED.chomp + Could not find team with name: `Foo` in frontend/javascripts/my_package. Make sure the team is one of `["Bar"]` + EXPECTED + end + end + end + end + end + + describe '.for_file' do + before { create_non_empty_application } + + it 'can find the owner of a ruby file with file annotations' do + expect(CodeOwnership.for_file('packs/my_pack/owned_file.rb')).to eq Teams.find('Bar') + end + + it 'can find the owner of a javascript file with file annotations' do + expect(CodeOwnership.for_file('frontend/javascripts/packages/my_package/owned_file.jsx')).to eq Teams.find('Bar') + end + + it 'can find the owner of ruby files in owned_globs' do + expect(CodeOwnership.for_file('app/services/bar_stuff/thing.rb')).to eq Teams.find('Bar') + end + + it 'can find the owner of javascript files in owned_globs' do + expect(CodeOwnership.for_file('frontend/javascripts/bar_stuff/thing.jsx')).to eq Teams.find('Bar') + end + + it 'can find the owner of files in team-owned packwerk packages' do + expect(CodeOwnership.for_file('packs/my_other_package/my_file.rb')).to eq Teams.find('Bar') + end + + it 'can find the owner of files in team-owned javascript packages' do + expect(CodeOwnership.for_file('frontend/javascripts/packages/my_other_package/my_file.jsx')).to eq Teams.find('Bar') + end + + describe 'path formatting expectations' do + # All file paths must be clean paths relative to the root: https://apidock.com/ruby/Pathname/cleanpath + it 'will not find the ownership of a file that is not a cleanpath' do + expect(CodeOwnership.for_file('./packs/my_pack/owned_file.rb')).to eq nil + expect(CodeOwnership.for_file('./frontend/javascripts/packages/my_package/owned_file.jsx')).to eq nil + expect(CodeOwnership.for_file('./app/services/bar_stuff/thing.rb')).to eq nil + expect(CodeOwnership.for_file('./frontend/javascripts/bar_stuff/thing.jsx')).to eq nil + expect(CodeOwnership.for_file('./packs/my_other_package/my_file.rb')).to eq nil + expect(CodeOwnership.for_file('./frontend/javascripts/packages/my_other_package/my_file.jsx')).to eq nil + end + end + end + + describe '.for_backtrace' do + def prevent_false_positive! + # The above code should raise, and we should never arrive at this next expectation. + # This is just to protect against a case where we have a false-postive test because the above does not raise. + expect(true).to eq false # rubocop:disable RSpec/ExpectActual + end + + before do + create_files_with_defined_classe + end + + context 'excluded_teams is not passed in as an input parameter' do + it 'finds the right team' do + begin # rubocop:disable Style/RedundantBegin + MyFile.raise_error + prevent_false_positive! + rescue StandardError => ex + expect(CodeOwnership.for_backtrace(ex.backtrace)).to eq Teams.find('Bar') + end + end + end + + context 'excluded_teams is passed in as an input parameter' do + it 'ignores the first part of the stack trace and finds the next viable owner' do + begin # rubocop:disable Style/RedundantBegin + MyFile.raise_error + prevent_false_positive! + rescue StandardError => ex + team_to_exclude = Teams.find('Bar') + expect(CodeOwnership.for_backtrace(ex.backtrace, excluded_teams: [team_to_exclude])).to eq Teams.find('Foo') + end + end + end + end + + describe '.for_class' do + before { create_files_with_defined_classe } + + it 'can find the right owner for a class' do + expect(CodeOwnership.for_class(MyFile)).to eq Teams.find('Foo') + end + + it 'memoizes the values' do + expect(CodeOwnership.for_class(MyFile)).to eq Teams.find('Foo') + allow(CodeOwnership).to receive(:for_file) + allow(Object).to receive(:const_source_location) + expect(CodeOwnership.for_class(MyFile)).to eq Teams.find('Foo') + + # Memoization should avoid these calls + expect(CodeOwnership).to_not have_received(:for_file) + expect(Object).to_not have_received(:const_source_location) + end + end + + describe '.for_package' do + before { create_non_empty_application } + + it 'returns the right team' do + team = CodeOwnership.for_package(ParsePackwerk.all.last) + expect(team.name).to eq 'Bar' + end + end + + describe '.remove_file_annotation!' do + subject(:remove_file_annotation) do + CodeOwnership.remove_file_annotation!(filename) + # Getting the owner gets stored in the cache, so after we remove the file annotation we want to bust the cache + CodeOwnership.bust_caches! + end + + before do + write_file('config/teams/foo.yml', <<~CONTENTS) + name: Foo + CONTENTS + end + + context 'ruby file has no annotation' do + let(:filename) { 'app/my_file.rb' } + + before do + write_file(filename, <<~CONTENTS) + # Empty file + CONTENTS + end + + it 'has no effect' do + expect(File.read(filename)).to eq "# Empty file\n" + + remove_file_annotation + + expect(File.read(filename)).to eq "# Empty file\n" + end + end + + context 'ruby file has annotation' do + let(:filename) { 'app/my_file.rb' } + + before do + write_file(filename, <<~CONTENTS) + # @team Foo + + # Some content + CONTENTS + end + + it 'removes the annotation' do + current_ownership = CodeOwnership.for_file(filename) + expect(current_ownership&.name).to eq 'Foo' + expect(File.read(filename)).to eq <<~RUBY + # @team Foo + + # Some content + RUBY + + remove_file_annotation + + new_ownership = CodeOwnership.for_file(filename) + expect(new_ownership).to eq nil + expected_output = <<~RUBY + # Some content + RUBY + + expect(File.read(filename)).to eq expected_output + end + end + + context 'javascript file has annotation' do + let(:filename) { 'app/my_file.jsx' } + + before do + write_file(filename, <<~CONTENTS) + // @team Foo + + // Some content + CONTENTS + end + + it 'removes the annotation' do + current_ownership = CodeOwnership.for_file(filename) + expect(current_ownership&.name).to eq 'Foo' + expect(File.read(filename)).to eq <<~JAVASCRIPT + // @team Foo + + // Some content + JAVASCRIPT + + remove_file_annotation + + new_ownership = CodeOwnership.for_file(filename) + expect(new_ownership).to eq nil + expected_output = <<~JAVASCRIPT + // Some content + JAVASCRIPT + + expect(File.read(filename)).to eq expected_output + end + end + + context 'file has new lines after the annotation' do + let(:filename) { 'app/my_file.rb' } + + before do + write_file(filename, <<~CONTENTS) + # @team Foo + + + # Some content + + + # Some other content + CONTENTS + end + + it 'removes the annotation and the leading new lines' do + expect(File.read(filename)).to eq <<~RUBY + # @team Foo + + + # Some content + + + # Some other content + RUBY + + remove_file_annotation + + expected_output = <<~RUBY + # Some content + + + # Some other content + RUBY + + expect(File.read(filename)).to eq expected_output + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..a03386d --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,42 @@ +require 'bundler/setup' +require 'pry' +require 'code_ownership' +require 'teams' + +require_relative 'support/application_fixtures' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + config.around do |example| + prefix = [File.basename($0), Process.pid].join('-') # rubocop:disable Style/SpecialGlobalVars + tmpdir = Dir.mktmpdir(prefix) + Dir.chdir(tmpdir) do + example.run + end + ensure + FileUtils.rm_rf(tmpdir) + end + + config.include_context 'application fixtures' + + config.before do + CodeOwnership.bust_caches! + Teams.bust_caches! + allow(Teams::Plugin).to receive(:registry).and_return({}) + end +end + +def write_file(path, content = '') + pathname = Pathname.new(path) + FileUtils.mkdir_p(pathname.dirname) + pathname.write(content) +end diff --git a/spec/support/application_fixtures.rb b/spec/support/application_fixtures.rb new file mode 100644 index 0000000..7a64565 --- /dev/null +++ b/spec/support/application_fixtures.rb @@ -0,0 +1,93 @@ +RSpec.shared_context 'application fixtures' do + let(:create_non_empty_application) do + write_file('config/code_ownership.yml', <<~YML) + owned_globs: + - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}' + YML + + write_file('packs/my_pack/owned_file.rb', <<~CONTENTS) + # @team Bar + CONTENTS + + write_file('frontend/javascripts/packages/my_package/owned_file.jsx', <<~CONTENTS) + // @team Bar + CONTENTS + + write_file('frontend/javascripts/packages/my_other_package/package.json', <<~CONTENTS) + { + "name": "@gusto/my_package", + "metadata": { + "owner": "Bar" + } + } + CONTENTS + write_file('frontend/javascripts/packages/my_other_package/my_file.jsx') + + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + github: + team: '@MyOrg/bar-team' + owned_globs: + - app/services/bar_stuff/** + - frontend/javascripts/bar_stuff/** + CONTENTS + + write_file('app/services/bar_stuff/thing.rb') + write_file('frontend/javascripts/bar_stuff/thing.jsx') + + write_file('packs/my_other_package/package.yml', <<~CONTENTS) + enforce_dependency: true + enforce_privacy: true + metadata: + owner: Bar + CONTENTS + + write_file('packs/my_other_package/my_file.rb') + end + + let(:create_minimal_configuration) do + write_file('config/code_ownership.yml', <<~YML) + owned_globs: + - app/**/*.rb + YML + end + + let(:create_files_with_defined_classe) do + write_file('app/my_file.rb', <<~CONTENTS) + # @team Foo + + require_relative 'my_error' + + class MyFile + def self.raise_error + MyError.raise_error + end + end + CONTENTS + + write_file('app/my_error.rb', <<~CONTENTS) + # @team Bar + + class MyError + def self.raise_error + raise "some error" + end + end + CONTENTS + + write_file('config/teams/foo.yml', <<~CONTENTS) + name: Foo + CONTENTS + + write_file('config/teams/bar.yml', <<~CONTENTS) + name: Bar + CONTENTS + + # Some of the tests use the `SequoiaTree` constant. Since the implementation leverages: + # `path = Object.const_source_location(klass.to_s)&.first`, we want to make sure that + # we re-require the constant each time, since `RSpecTempfiles` changes where the file lives with each test + Object.send(:remove_const, :MyFile) if defined? MyFile # rubocop:disable Style/Send: + Object.send(:remove_const, :MyError) if defined? MyError # rubocop:disable Style/Send: + require Pathname.pwd.join('app/my_file') + end +end