diff --git a/.gitignore b/.gitignore index b04a8c8..85f65a4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /doc/ /pkg/ /spec/reports/ +/vendor/bundle /tmp/ # rspec failure tracking diff --git a/.rubocop.yml b/.rubocop.yml index f9e12bb..7feb128 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -110,6 +110,9 @@ Layout/MultilineMethodCallIndentation: Style/BlockDelimiters: Enabled: false +Style/StringLiterals: + Enabled: false + # Sometimes we like methods like `get_packages` Naming/AccessorMethodName: Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index cc7ddc0..8cda53d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,14 @@ PATH remote: . specs: - code_teams (1.0.2) + code_teams (1.1.0) sorbet-runtime GEM remote: https://rubygems.org/ specs: ast (2.4.3) - benchmark (0.4.0) + benchmark (0.4.1) coderay (1.1.3) diff-lcs (1.6.2) erubi (1.13.1) @@ -71,13 +71,14 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) - sorbet (0.5.12134) - sorbet-static (= 0.5.12134) - sorbet-runtime (0.5.12134) - sorbet-static (0.5.12134-universal-darwin) - sorbet-static-and-runtime (0.5.12134) - sorbet (= 0.5.12134) - sorbet-runtime (= 0.5.12134) + sorbet (0.5.12142) + sorbet-static (= 0.5.12142) + sorbet-runtime (0.5.12142) + sorbet-static (0.5.12142-universal-darwin) + sorbet-static (0.5.12142-x86_64-linux) + sorbet-static-and-runtime (0.5.12142) + sorbet (= 0.5.12142) + sorbet-runtime (= 0.5.12142) spoom (1.6.3) erubi (>= 1.10.0) prism (>= 0.28.0) diff --git a/README.md b/README.md index 05ee4a9..4b7d4a6 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,43 @@ github: ``` 1) You can now use the following API to get GitHub information about that team: -```ruby -team = CodeTeams.find('My Team') -MyGithubPlugin.for(team).github -``` + + ```ruby + team = CodeTeams.find('My Team') + members = team.github.members + github_name = team.github.team + ``` + + Alternatively, you can assign an accessor method name that differs from the plugin's class name: + + ```ruby + class MyPlugin < CodeTeams::Plugin + data_accessor_name :other_name + + def other_name + # ... + end + end + + # You can then use: + team.other_name + # similarly to the Github example above + # You can then access data in the following manner: + team.other_name.attribute_name + ``` + + However, to avoid confusion, it's recommended to use the naming convention + whenever possible so that your accessor name matches your plugin's name + 2) Running team validations (see below) will ensure all teams have a GitHub team specified -Your plugins can be as simple or as complex as you want. Here are some other things we use plugins for: -- Identifying which teams own which feature flags -- Mapping teams to specific portions of the code through `code_ownership` -- Allowing teams to protect certain files and require approval on modification of certain files -- Specifying owned dependencies (Ruby gems, JavaScript packages, and more) -- Specifying how to get in touch with the team via Slack (their channel and handle) + Your plugins can be as simple or as complex as you want. Here are some other things we use plugins for: + + - Identifying which teams own which feature flags + - Mapping teams to specific portions of the code through `code_ownership` + - Allowing teams to protect certain files and require approval on modification of certain files + - Specifying owned dependencies (Ruby gems, JavaScript packages, and more) + - Specifying how to get in touch with the team via Slack (their channel and handle) ## Configuration You'll want to ensure that all teams are valid in your CI environment. We recommend running code like this in CI: diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..cb53ebe --- /dev/null +++ b/bin/rspec @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/code_teams.gemspec b/code_teams.gemspec index 533451e..9a9a1f7 100644 --- a/code_teams.gemspec +++ b/code_teams.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |spec| spec.name = 'code_teams' - spec.version = '1.0.2' + spec.version = '1.1.0' spec.authors = ['Gusto Engineers'] spec.email = ['dev@gusto.com'] spec.summary = 'A low-dependency gem for declaring and querying engineering teams' diff --git a/lib/code_teams.rb b/lib/code_teams.rb index 95d825c..c1f341c 100644 --- a/lib/code_teams.rb +++ b/lib/code_teams.rb @@ -7,6 +7,7 @@ require 'sorbet-runtime' require 'code_teams/plugin' require 'code_teams/plugins/identity' +require 'code_teams/utils' module CodeTeams extend T::Sig @@ -14,6 +15,7 @@ module CodeTeams class IncorrectPublicApiUsageError < StandardError; end UNKNOWN_TEAM_STRING = 'Unknown Team' + @plugins_registered = T.let(false, T::Boolean) sig { returns(T::Array[Team]) } def self.all @@ -35,6 +37,11 @@ def self.find(name) sig { params(dir: String).returns(T::Array[Team]) } def self.for_directory(dir) + unless @plugins_registered + Team.register_plugins + @plugins_registered = true + end + Pathname.new(dir).glob('**/*.yml').map do |path| Team.from_yml(path.to_s) rescue Psych::SyntaxError @@ -59,6 +66,7 @@ def self.tag_value_for(string) # The primary reason this is helpful is for clients of CodeTeams who want to test their code, and each test context has different set of teams sig { void } def self.bust_caches! + @plugins_registered = false Plugin.bust_caches! @all = nil @index_by_name = nil @@ -85,6 +93,17 @@ def self.from_hash(raw_hash) ) end + sig { void } + def self.register_plugins + Plugin.all_plugins.each do |plugin| + # e.g., def github (on Team) + define_method(plugin.data_accessor_name) do + # e.g., MyGithubPlugin.for(team).github + plugin.for(T.cast(self, Team)).public_send(plugin.data_accessor_name) + end + end + end + sig { returns(T::Hash[T.untyped, T.untyped]) } attr_reader :raw_hash diff --git a/lib/code_teams/plugin.rb b/lib/code_teams/plugin.rb index b87ea21..2735f31 100644 --- a/lib/code_teams/plugin.rb +++ b/lib/code_teams/plugin.rb @@ -10,11 +10,24 @@ class Plugin abstract! + @data_accessor_name = T.let(nil, T.nilable(String)) + sig { params(team: Team).void } def initialize(team) @team = team end + sig { params(key: String).returns(String) } + def self.data_accessor_name(key = default_data_accessor_name) + @data_accessor_name ||= key + end + + sig { returns(String) } + def self.default_data_accessor_name + # e.g., MyNamespace::MyPlugin -> my_plugin + Utils.underscore(Utils.demodulize(name)) + end + sig { params(base: T.untyped).void } def self.inherited(base) # rubocop:disable Lint/MissingSuper all_plugins << T.cast(base, T.class_of(Plugin)) diff --git a/lib/code_teams/utils.rb b/lib/code_teams/utils.rb new file mode 100644 index 0000000..281cddb --- /dev/null +++ b/lib/code_teams/utils.rb @@ -0,0 +1,17 @@ +module CodeTeams + module Utils + module_function + + def underscore(string) + string.gsub('::', '/') + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr('-', '_') + .downcase + end + + def demodulize(string) + string.split('::').last + end + end +end diff --git a/sorbet/rbi/todo.rbi b/sorbet/rbi/todo.rbi index 5845608..389b1a4 100644 --- a/sorbet/rbi/todo.rbi +++ b/sorbet/rbi/todo.rbi @@ -4,3 +4,4 @@ # typed: strong module ::RSpec; end module ::TestPlugin; end +module TestNamespace::TestPlugin; end diff --git a/spec/code_teams/plugin_helper_integration_spec.rb b/spec/code_teams/plugin_helper_integration_spec.rb new file mode 100644 index 0000000..ca5b40d --- /dev/null +++ b/spec/code_teams/plugin_helper_integration_spec.rb @@ -0,0 +1,63 @@ +RSpec.describe CodeTeams::Plugin do + before do + CodeTeams.bust_caches! + write_team_yml(extra_data: { 'foo' => 'foo', 'bar' => 'bar' }) + end + + let(:team) { CodeTeams.find('My Team') } + + describe 'helper methods' do + context 'with a single implicit method' do + before do + test_plugin_class = Class.new(described_class) do + def test_plugin + data = @team.raw_hash['extra_data'] + Data.define(:foo, :bar).new(data['foo'], data['bar']) + end + end + + stub_const('TestPlugin', test_plugin_class) + end + + it 'adds a helper method to the team' do + expect(team.test_plugin.foo).to eq('foo') + expect(team.test_plugin.bar).to eq('bar') + end + + it 'supports nested data' do + write_team_yml(extra_data: { 'foo' => { 'bar' => 'bar' } }) + expect(team.test_plugin.foo['bar']).to eq('bar') + end + end + + context 'when the data accessor name is overridden' do + before do + test_plugin_class = Class.new(described_class) do + data_accessor_name 'foo' + + def foo + Data.define(:bar).new('bar') + end + end + + stub_const('TestPlugin', test_plugin_class) + end + + it 'adds the data accessor name to the team' do + expect(team.foo.bar).to eq('bar') + end + end + end + + specify 'backwards compatibility' do + test_plugin_class = Class.new(described_class) do + def test_plugin + Data.define(:foo).new('foo') + end + end + + stub_const('TestPlugin', test_plugin_class) + + expect(TestPlugin.for(team).test_plugin.foo).to eq('foo') + end +end diff --git a/spec/lib/code_teams/plugin_spec.rb b/spec/lib/code_teams/plugin_spec.rb index d9bdc2d..e2ba61f 100644 --- a/spec/lib/code_teams/plugin_spec.rb +++ b/spec/lib/code_teams/plugin_spec.rb @@ -1,31 +1,41 @@ -RSpec.describe CodeTeams::Plugin do - def write_team_yml(extra_data: false) - write_file('config/teams/my_team.yml', <<~YML.strip) - name: My Team - extra_data: #{extra_data} - YML - end - - before do - CodeTeams.bust_caches! - - test_plugin_class = Class.new(described_class) do - def extra_data - @team.raw_hash['extra_data'] - end - end - stub_const('TestPlugin', test_plugin_class) - end +module TestNamespace; end +RSpec.describe CodeTeams::Plugin do describe '.bust_caches!' do it 'clears all plugins team registries ensuring cached configs are purged' do + test_plugin_class = Class.new(described_class) do + def extra_data + @team.raw_hash['extra_data'] + end + end + stub_const('TestNamespace::TestPlugin', test_plugin_class) + + CodeTeams.bust_caches! write_team_yml(extra_data: true) team = CodeTeams.find('My Team') - expect(TestPlugin.for(team).extra_data).to be(true) + expect(TestNamespace::TestPlugin.for(team).extra_data).to be(true) write_team_yml(extra_data: false) CodeTeams.bust_caches! team = CodeTeams.find('My Team') - expect(TestPlugin.for(team).extra_data).to be(false) + expect(TestNamespace::TestPlugin.for(team).extra_data).to be(false) + end + end + + describe '.data_accessor_name' do + it 'returns the underscore version of the plugin name' do + test_plugin_class = Class.new(described_class) + stub_const('TestNamespace::TestPlugin', test_plugin_class) + + expect(TestNamespace::TestPlugin.data_accessor_name).to eq('test_plugin') + end + + it 'can be overridden by a subclass' do + test_plugin_class = Class.new(described_class) do + data_accessor_name 'foo' + end + stub_const('TestNamespace::TestPlugin', test_plugin_class) + + expect(TestNamespace::TestPlugin.data_accessor_name).to eq('foo') end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2f23b3e..087ba37 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,8 @@ require 'pry' require 'code_teams' +Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' @@ -23,9 +25,3 @@ FileUtils.rm_rf(tmpdir) 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/io_helpers.rb b/spec/support/io_helpers.rb new file mode 100644 index 0000000..f9cb30c --- /dev/null +++ b/spec/support/io_helpers.rb @@ -0,0 +1,18 @@ +module IOHelpers + def write_team_yml(extra_data: false) + write_file('config/teams/my_team.yml', YAML.dump({ + name: 'My Team', + extra_data: extra_data + }.transform_keys(&:to_s))) + end + + def write_file(path, content = '') + pathname = Pathname.new(path) + FileUtils.mkdir_p(pathname.dirname) + pathname.write(content) + end +end + +RSpec.configure do |config| + config.include IOHelpers +end