├── .rspec ├── CHANGELOG.md ├── sorbet ├── config └── rbi │ └── todo.rbi ├── Rakefile ├── .gitignore ├── .github └── workflows │ ├── stale.yml │ ├── triage.yml │ ├── ci.yml │ └── cd.yml ├── Gemfile ├── lib ├── code_teams │ ├── utils.rb │ ├── plugins │ │ └── identity.rb │ └── plugin.rb └── code_teams.rb ├── spec ├── support │ └── io_helpers.rb ├── spec_helper.rb ├── lib │ ├── code_teams │ │ └── plugin_spec.rb │ └── code_teams_spec.rb └── code_teams │ └── plugin_helper_integration_spec.rb ├── bin ├── srb ├── rspec ├── rubocop └── srb-rbi ├── LICENSE ├── code_teams.gemspec ├── Gemfile.lock ├── README.md ├── .rubocop.yml └── CODE_OF_CONDUCT.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See https://github.com/rubyatscale/code_teams/releases 2 | -------------------------------------------------------------------------------- /sorbet/config: -------------------------------------------------------------------------------- 1 | --dir 2 | . 3 | --ignore=/vendor/bundle 4 | --cache-dir=tmp/cache/sorbet 5 | --ignore=tmp/ 6 | --ignore=vendor/ 7 | --ignore=spec/ 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # typed: ignore 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /vendor/bundle 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /sorbet/rbi/todo.rbi: -------------------------------------------------------------------------------- 1 | # This file is autogenerated. Do not edit it by hand. Regenerate it with: 2 | # srb rbi todo 3 | 4 | # typed: strong 5 | module ::RSpec; end 6 | module ::TestPlugin; end 7 | module TestNamespace::TestPlugin; end 8 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | jobs: 7 | call-workflow-from-shared-config: 8 | uses: rubyatscale/shared-config/.github/workflows/stale.yml@main 9 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: Label issues as "triage" 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | jobs: 8 | call-workflow-from-shared-config: 9 | uses: rubyatscale/shared-config/.github/workflows/triage.yml@main 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | call-workflow-from-shared-config: 11 | uses: rubyatscale/shared-config/.github/workflows/ci.yml@main 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in code_teams.gemspec 4 | gemspec 5 | 6 | gem 'pry' 7 | gem 'rake' 8 | gem 'rspec', '~> 3.0' 9 | gem 'rubocop' 10 | gem 'rubocop-rake' 11 | gem 'rubocop-rspec' 12 | gem 'sorbet' 13 | gem 'tapioca' 14 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | types: [completed] 7 | branches: [main] 8 | 9 | jobs: 10 | call-workflow-from-shared-config: 11 | uses: rubyatscale/shared-config/.github/workflows/cd.yml@main 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /lib/code_teams/utils.rb: -------------------------------------------------------------------------------- 1 | module CodeTeams 2 | module Utils 3 | module_function 4 | 5 | def underscore(string) 6 | string.gsub('::', '/') 7 | .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 8 | .gsub(/([a-z\d])([A-Z])/, '\1_\2') 9 | .tr('-', '_') 10 | .downcase 11 | end 12 | 13 | def demodulize(string) 14 | string.split('::').last 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/io_helpers.rb: -------------------------------------------------------------------------------- 1 | module IOHelpers 2 | def write_team_yml(extra_data: false) 3 | write_file('config/teams/my_team.yml', YAML.dump({ 4 | name: 'My Team', 5 | extra_data: extra_data 6 | }.transform_keys(&:to_s))) 7 | end 8 | 9 | def write_file(path, content = '') 10 | pathname = Pathname.new(path) 11 | FileUtils.mkdir_p(pathname.dirname) 12 | pathname.write(content) 13 | end 14 | end 15 | 16 | RSpec.configure do |config| 17 | config.include IOHelpers 18 | end 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'pry' 3 | require 'code_teams' 4 | 5 | Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = '.rspec_status' 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | 18 | config.around do |example| 19 | prefix = [File.basename($0), Process.pid].join('-') # rubocop:disable Style/SpecialGlobalVars 20 | tmpdir = Dir.mktmpdir(prefix) 21 | Dir.chdir(tmpdir) do 22 | example.run 23 | end 24 | ensure 25 | FileUtils.rm_rf(tmpdir) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /bin/srb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'srb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("sorbet", "srb") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rubocop", "rubocop") 28 | -------------------------------------------------------------------------------- /bin/srb-rbi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'srb-rbi' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("sorbet", "srb-rbi") 28 | -------------------------------------------------------------------------------- /lib/code_teams/plugins/identity.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | 3 | module CodeTeams 4 | module Plugins 5 | class Identity < Plugin 6 | extend T::Sig 7 | extend T::Helpers 8 | 9 | IdentityStruct = Struct.new(:name) 10 | 11 | sig { returns(IdentityStruct) } 12 | def identity 13 | IdentityStruct.new( 14 | @team.raw_hash['name'] 15 | ) 16 | end 17 | 18 | sig { override.params(teams: T::Array[CodeTeams::Team]).returns(T::Array[String]) } 19 | def self.validation_errors(teams) 20 | errors = T.let([], T::Array[String]) 21 | 22 | uniq_set = Set.new 23 | teams.each do |team| 24 | for_team = self.for(team) 25 | 26 | if !uniq_set.add?(for_team.identity.name) 27 | errors << "More than 1 definition for #{for_team.identity.name} found" 28 | end 29 | 30 | errors << missing_key_error_message(team, 'name') if for_team.identity.name.nil? 31 | end 32 | 33 | errors 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gusto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /code_teams.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |spec| 2 | spec.name = 'code_teams' 3 | spec.version = '1.2.0' 4 | spec.authors = ['Gusto Engineers'] 5 | spec.email = ['dev@gusto.com'] 6 | spec.summary = 'A low-dependency gem for declaring and querying engineering teams' 7 | spec.description = 'A low-dependency gem for declaring and querying engineering teams' 8 | spec.homepage = 'https://github.com/rubyatscale/code_teams' 9 | spec.license = 'MIT' 10 | 11 | if spec.respond_to?(:metadata) 12 | spec.metadata['homepage_uri'] = spec.homepage 13 | spec.metadata['source_code_uri'] = 'https://github.com/rubyatscale/code_teams' 14 | spec.metadata['changelog_uri'] = 'https://github.com/rubyatscale/code_teams/releases' 15 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 16 | else 17 | raise 'RubyGems 2.0 or newer is required to protect against ' \ 18 | 'public gem pushes.' 19 | end 20 | 21 | spec.files = Dir['README.md', 'lib/**/*'] 22 | spec.require_paths = ['lib'] 23 | spec.required_ruby_version = '>= 3.2' 24 | 25 | spec.add_dependency 'sorbet-runtime' 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/code_teams/plugin_spec.rb: -------------------------------------------------------------------------------- 1 | module TestNamespace; end 2 | 3 | RSpec.describe CodeTeams::Plugin do 4 | describe '.bust_caches!' do 5 | it 'clears all plugins team registries ensuring cached configs are purged' do 6 | test_plugin_class = Class.new(described_class) do 7 | def extra_data 8 | @team.raw_hash['extra_data'] 9 | end 10 | end 11 | stub_const('TestNamespace::TestPlugin', test_plugin_class) 12 | 13 | CodeTeams.bust_caches! 14 | write_team_yml(extra_data: true) 15 | team = CodeTeams.find('My Team') 16 | expect(TestNamespace::TestPlugin.for(team).extra_data).to be(true) 17 | write_team_yml(extra_data: false) 18 | CodeTeams.bust_caches! 19 | team = CodeTeams.find('My Team') 20 | expect(TestNamespace::TestPlugin.for(team).extra_data).to be(false) 21 | end 22 | end 23 | 24 | describe '.data_accessor_name' do 25 | it 'returns the underscore version of the plugin name' do 26 | test_plugin_class = Class.new(described_class) 27 | stub_const('TestNamespace::TestPlugin', test_plugin_class) 28 | 29 | expect(TestNamespace::TestPlugin.data_accessor_name).to eq('test_plugin') 30 | end 31 | 32 | it 'can be overridden by a subclass' do 33 | test_plugin_class = Class.new(described_class) do 34 | data_accessor_name 'foo' 35 | end 36 | stub_const('TestNamespace::TestPlugin', test_plugin_class) 37 | 38 | expect(TestNamespace::TestPlugin.data_accessor_name).to eq('foo') 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/code_teams/plugin_helper_integration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CodeTeams::Plugin do 2 | before do 3 | CodeTeams.bust_caches! 4 | write_team_yml(extra_data: { 'foo' => 'foo', 'bar' => 'bar' }) 5 | end 6 | 7 | let(:team) { CodeTeams.find('My Team') } 8 | 9 | describe 'helper methods' do 10 | context 'with a single implicit method' do 11 | before do 12 | test_plugin_class = Class.new(described_class) do 13 | def test_plugin 14 | data = @team.raw_hash['extra_data'] 15 | Data.define(:foo, :bar).new(data['foo'], data['bar']) 16 | end 17 | end 18 | 19 | stub_const('TestPlugin', test_plugin_class) 20 | end 21 | 22 | it 'adds a helper method to the team' do 23 | expect(team.test_plugin.foo).to eq('foo') 24 | expect(team.test_plugin.bar).to eq('bar') 25 | end 26 | 27 | it 'supports nested data' do 28 | write_team_yml(extra_data: { 'foo' => { 'bar' => 'bar' } }) 29 | expect(team.test_plugin.foo['bar']).to eq('bar') 30 | end 31 | end 32 | 33 | context 'when the data accessor name is overridden' do 34 | before do 35 | test_plugin_class = Class.new(described_class) do 36 | data_accessor_name 'foo' 37 | 38 | def foo 39 | Data.define(:bar).new('bar') 40 | end 41 | end 42 | 43 | stub_const('TestPlugin', test_plugin_class) 44 | end 45 | 46 | it 'adds the data accessor name to the team' do 47 | expect(team.foo.bar).to eq('bar') 48 | end 49 | end 50 | end 51 | 52 | specify 'backwards compatibility' do 53 | test_plugin_class = Class.new(described_class) do 54 | def test_plugin 55 | Data.define(:foo).new('foo') 56 | end 57 | end 58 | 59 | stub_const('TestPlugin', test_plugin_class) 60 | 61 | expect(TestPlugin.for(team).test_plugin.foo).to eq('foo') 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/code_teams/plugin.rb: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module CodeTeams 4 | # Plugins allow a client to add validation on custom keys in the team YML. 5 | # For now, only a single plugin is allowed to manage validation on a top-level key. 6 | # In the future we can think of allowing plugins to be gracefully merged with each other. 7 | class Plugin 8 | extend T::Helpers 9 | extend T::Sig 10 | 11 | abstract! 12 | 13 | @data_accessor_name = T.let(nil, T.nilable(String)) 14 | 15 | sig { params(team: Team).void } 16 | def initialize(team) 17 | @team = team 18 | end 19 | 20 | sig { params(key: String).returns(String) } 21 | def self.data_accessor_name(key = default_data_accessor_name) 22 | @data_accessor_name ||= key 23 | end 24 | 25 | sig { returns(String) } 26 | def self.default_data_accessor_name 27 | # e.g., MyNamespace::MyPlugin -> my_plugin 28 | Utils.underscore(Utils.demodulize(name)) 29 | end 30 | 31 | sig { params(base: T.untyped).void } 32 | def self.inherited(base) # rubocop:disable Lint/MissingSuper 33 | all_plugins << T.cast(base, T.class_of(Plugin)) 34 | end 35 | 36 | sig { returns(T::Array[T.class_of(Plugin)]) } 37 | def self.all_plugins 38 | @all_plugins ||= T.let(@all_plugins, T.nilable(T::Array[T.class_of(Plugin)])) 39 | @all_plugins ||= [] 40 | @all_plugins 41 | end 42 | 43 | sig { params(teams: T::Array[Team]).returns(T::Array[String]) } 44 | def self.validation_errors(teams) 45 | [] 46 | end 47 | 48 | sig { params(team: Team).returns(T.attached_class) } 49 | def self.for(team) 50 | register_team(team) 51 | end 52 | 53 | sig { params(team: Team, key: String).returns(String) } 54 | def self.missing_key_error_message(team, key) 55 | "#{team.name} is missing required key `#{key}`" 56 | end 57 | 58 | sig { returns(T::Hash[T.nilable(String), T::Hash[T.class_of(Plugin), Plugin]]) } 59 | def self.registry 60 | @registry ||= T.let(@registry, T.nilable(T::Hash[String, T::Hash[T.class_of(Plugin), Plugin]])) 61 | @registry ||= {} 62 | @registry 63 | end 64 | 65 | sig { params(team: Team).returns(T.attached_class) } 66 | def self.register_team(team) 67 | # We pull from the hash since `team.name` uses the registry 68 | team_name = team.raw_hash['name'] 69 | 70 | registry[team_name] ||= {} 71 | registry_for_team = registry[team_name] || {} 72 | registry[team_name] ||= {} 73 | registry_for_team[self] ||= new(team) 74 | T.unsafe(registry_for_team[self]) 75 | end 76 | 77 | sig { void } 78 | def self.bust_caches! 79 | all_plugins.each(&:clear_team_registry!) 80 | end 81 | 82 | sig { void } 83 | def self.clear_team_registry! 84 | @registry = nil 85 | end 86 | 87 | private_class_method :registry 88 | private_class_method :register_team 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/lib/code_teams_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe CodeTeams do 2 | let(:team_yml) do 3 | <<~YML.strip 4 | name: My Team 5 | YML 6 | end 7 | 8 | before do 9 | write_file('config/teams/my_team.yml', team_yml) 10 | described_class.bust_caches! 11 | allow(CodeTeams::Plugin).to receive(:registry).and_return({}) 12 | end 13 | 14 | describe '.all' do 15 | it 'correctly parses the team files' do 16 | expect(described_class.all.count).to eq 1 17 | team = described_class.all.first 18 | expect(team.name).to eq 'My Team' 19 | expect(team.raw_hash['name']).to eq 'My Team' 20 | expect(team.config_yml).to eq 'config/teams/my_team.yml' 21 | end 22 | 23 | context 'if team YML has syntax errors' do 24 | let(:team_yml) do 25 | <<~YML.strip 26 | name =>>>asdfaf!!@#!@#@!syntax error My Team 27 | asdfsa: asdfs 28 | YML 29 | end 30 | 31 | it 'spits out a helpful error message' do 32 | expect { described_class.all }.to raise_error do |e| 33 | expect(e).to be_a CodeTeams::IncorrectPublicApiUsageError 34 | expect(e.message).to eq('The YML in config/teams/my_team.yml has a syntax error!') 35 | end 36 | end 37 | end 38 | end 39 | 40 | describe '.find' do 41 | it 'returns the team when found' do 42 | team = described_class.find('My Team') 43 | expect(team).to be_a(CodeTeams::Team) 44 | expect(team.name).to eq('My Team') 45 | end 46 | 47 | it 'returns nil when the team is not found' do 48 | team = described_class.find('Nonexistent Team') 49 | expect(team).to be_nil 50 | end 51 | end 52 | 53 | describe '.find!' do 54 | it 'returns the team when found' do 55 | team = described_class.find!('My Team') 56 | expect(team).to be_a(CodeTeams::Team) 57 | expect(team.name).to eq('My Team') 58 | end 59 | 60 | it 'raises TeamNotFoundError when the team is not found' do 61 | expect { described_class.find!('Nonexistent Team') }.to raise_error( 62 | CodeTeams::TeamNotFoundError, 63 | 'No team found with name: Nonexistent Team' 64 | ) 65 | end 66 | end 67 | 68 | describe 'validation_errors' do 69 | subject(:validation_errors) { described_class.validation_errors(described_class.all) } 70 | 71 | context 'there is one definition for all teams' do 72 | it 'has no errors' do 73 | expect(validation_errors).to be_empty 74 | end 75 | end 76 | 77 | context 'there are multiple definitions for the same team' do 78 | before do 79 | write_file('config/teams/my_other_team.yml', team_yml) 80 | end 81 | 82 | it 'registers the team file as invalid' do 83 | expect(validation_errors).to contain_exactly('More than 1 definition for My Team found') 84 | end 85 | end 86 | end 87 | 88 | describe '==' do 89 | it 'handles nil correctly' do 90 | expect(described_class.all.first == nil).to be false # rubocop:disable Style/NilComparison 91 | expect(described_class.all.first.nil?).to be false 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | code_teams (1.2.0) 5 | sorbet-runtime 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.3) 11 | benchmark (0.4.1) 12 | coderay (1.1.3) 13 | diff-lcs (1.6.2) 14 | erubi (1.13.1) 15 | json (2.12.2) 16 | language_server-protocol (3.17.0.5) 17 | lint_roller (1.1.0) 18 | logger (1.7.0) 19 | method_source (1.1.0) 20 | netrc (0.11.0) 21 | parallel (1.27.0) 22 | parser (3.3.8.0) 23 | ast (~> 2.4.1) 24 | racc 25 | prism (1.4.0) 26 | pry (0.15.2) 27 | coderay (~> 1.1) 28 | method_source (~> 1.0) 29 | racc (1.8.1) 30 | rainbow (3.1.1) 31 | rake (13.3.0) 32 | rbi (0.3.3) 33 | prism (~> 1.0) 34 | rbs (>= 3.4.4) 35 | sorbet-runtime (>= 0.5.9204) 36 | rbs (3.9.4) 37 | logger 38 | regexp_parser (2.10.0) 39 | rexml (3.4.1) 40 | rspec (3.13.1) 41 | rspec-core (~> 3.13.0) 42 | rspec-expectations (~> 3.13.0) 43 | rspec-mocks (~> 3.13.0) 44 | rspec-core (3.13.4) 45 | rspec-support (~> 3.13.0) 46 | rspec-expectations (3.13.5) 47 | diff-lcs (>= 1.2.0, < 2.0) 48 | rspec-support (~> 3.13.0) 49 | rspec-mocks (3.13.5) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.13.0) 52 | rspec-support (3.13.4) 53 | rubocop (1.75.8) 54 | json (~> 2.3) 55 | language_server-protocol (~> 3.17.0.2) 56 | lint_roller (~> 1.1.0) 57 | parallel (~> 1.10) 58 | parser (>= 3.3.0.2) 59 | rainbow (>= 2.2.2, < 4.0) 60 | regexp_parser (>= 2.9.3, < 3.0) 61 | rubocop-ast (>= 1.44.0, < 2.0) 62 | ruby-progressbar (~> 1.7) 63 | unicode-display_width (>= 2.4.0, < 4.0) 64 | rubocop-ast (1.44.1) 65 | parser (>= 3.3.7.2) 66 | prism (~> 1.4) 67 | rubocop-rake (0.7.1) 68 | lint_roller (~> 1.1) 69 | rubocop (>= 1.72.1) 70 | rubocop-rspec (3.6.0) 71 | lint_roller (~> 1.1) 72 | rubocop (~> 1.72, >= 1.72.1) 73 | ruby-progressbar (1.13.0) 74 | sorbet (0.5.12142) 75 | sorbet-static (= 0.5.12142) 76 | sorbet-runtime (0.5.12142) 77 | sorbet-static (0.5.12142-universal-darwin) 78 | sorbet-static (0.5.12142-x86_64-linux) 79 | sorbet-static-and-runtime (0.5.12142) 80 | sorbet (= 0.5.12142) 81 | sorbet-runtime (= 0.5.12142) 82 | spoom (1.6.3) 83 | erubi (>= 1.10.0) 84 | prism (>= 0.28.0) 85 | rbi (>= 0.3.3) 86 | rexml (>= 3.2.6) 87 | sorbet-static-and-runtime (>= 0.5.10187) 88 | thor (>= 0.19.2) 89 | tapioca (0.16.11) 90 | benchmark 91 | bundler (>= 2.2.25) 92 | netrc (>= 0.11.0) 93 | parallel (>= 1.21.0) 94 | rbi (~> 0.2) 95 | sorbet-static-and-runtime (>= 0.5.11087) 96 | spoom (>= 1.2.0) 97 | thor (>= 1.2.0) 98 | yard-sorbet 99 | thor (1.3.2) 100 | unicode-display_width (3.1.4) 101 | unicode-emoji (~> 4.0, >= 4.0.4) 102 | unicode-emoji (4.0.4) 103 | yard (0.9.37) 104 | yard-sorbet (0.9.0) 105 | sorbet-runtime 106 | yard 107 | 108 | PLATFORMS 109 | universal-darwin 110 | x86_64-linux 111 | 112 | DEPENDENCIES 113 | code_teams! 114 | pry 115 | rake 116 | rspec (~> 3.0) 117 | rubocop 118 | rubocop-rake 119 | rubocop-rspec 120 | sorbet 121 | tapioca 122 | 123 | BUNDLED WITH 124 | 2.4.7 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeTeams 2 | 3 | This gem is a simple, low-dependency, plugin-based manager for teams within a codebase. 4 | 5 | ## Usage 6 | 7 | To use `code_teams`, add YML files in `config/teams` that start with structure: 8 | `config/teams/my_team.yml` 9 | ```yml 10 | name: My Team 11 | ``` 12 | 13 | `code_teams` leverages a plugin system because every organization's team practices are different. Say your organization uses GitHub and wants to ensure every team YML files has a GitHub owner. To do this, you create a plugin: 14 | 15 | ```ruby 16 | class MyGithubPlugin < CodeTeams::Plugin 17 | extend T::Sig 18 | extend T::Helpers 19 | 20 | GithubStruct = Struct.new(:team, :members) 21 | 22 | sig { returns(GithubStruct) } 23 | def github 24 | raw_github = @team.raw_hash['github'] || {} 25 | 26 | GithubStruct.new( 27 | raw_github['team'], 28 | raw_github['members'] || [] 29 | ) 30 | end 31 | 32 | def member?(user) 33 | github.members.include?(user) 34 | end 35 | 36 | sig { override.params(teams: T::Array[CodeTeams::Team]).returns(T::Array[String]) } 37 | def self.validation_errors(teams) 38 | errors = T.let([], T::Array[String]) 39 | 40 | teams.each do |team| 41 | if self.for(team).github.team.nil? 42 | errors << missing_key_error_message(team, 'github.team') 43 | end 44 | end 45 | 46 | errors 47 | end 48 | end 49 | ``` 50 | 51 | After adding the proper GitHub information to the team YML: 52 | ```yml 53 | name: My Team 54 | github: 55 | team: '@org/my-team' 56 | members: 57 | - member1 58 | - member2 59 | ``` 60 | 61 | 1) You can now use the following API to get GitHub information about that team: 62 | 63 | ```ruby 64 | team = CodeTeams.find('My Team') 65 | members = team.github.members 66 | github_name = team.github.team 67 | ``` 68 | 69 | Alternatively, you can assign an accessor method name that differs from the plugin's class name: 70 | 71 | ```ruby 72 | class MyPlugin < CodeTeams::Plugin 73 | data_accessor_name :other_name 74 | 75 | def other_name 76 | # ... 77 | end 78 | end 79 | 80 | # You can then use: 81 | team.other_name 82 | # similarly to the Github example above 83 | # You can then access data in the following manner: 84 | team.other_name.attribute_name 85 | ``` 86 | 87 | However, to avoid confusion, it's recommended to use the naming convention 88 | whenever possible so that your accessor name matches your plugin's name 89 | 90 | 2) Running team validations (see below) will ensure all teams have a GitHub team specified 91 | 92 | Your plugins can be as simple or as complex as you want. Here are some other things we use plugins for: 93 | 94 | - Identifying which teams own which feature flags 95 | - Mapping teams to specific portions of the code through `code_ownership` 96 | - Allowing teams to protect certain files and require approval on modification of certain files 97 | - Specifying owned dependencies (Ruby gems, JavaScript packages, and more) 98 | - Specifying how to get in touch with the team via Slack (their channel and handle) 99 | 100 | ## Configuration 101 | You'll want to ensure that all teams are valid in your CI environment. We recommend running code like this in CI: 102 | ```ruby 103 | require 'code_teams' 104 | 105 | errors = CodeTeams.validation_errors(CodeTeams.all) 106 | if errors.any? 107 | abort <<~ERROR 108 | Team validation failed with the following errors: 109 | #{errors.join("\n")} 110 | ERROR 111 | end 112 | ``` 113 | 114 | ## Contributing 115 | 116 | Bug reports and pull requests are welcome! 117 | 118 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # The behavior of RuboCop can be controlled via the .rubocop.yml 2 | # configuration file. It makes it possible to enable/disable 3 | # certain cops (checks) and to alter their behavior if they accept 4 | # any parameters. The file can be placed either in your home 5 | # directory or in some project directory. 6 | # 7 | # RuboCop will start looking for the configuration file in the directory 8 | # where the inspected file is and continue its way up to the root directory. 9 | # 10 | # See https://docs.rubocop.org/rubocop/configuration 11 | 12 | plugins: 13 | - rubocop-rake 14 | - rubocop-rspec 15 | 16 | AllCops: 17 | NewCops: enable 18 | Exclude: 19 | - vendor/bundle/**/** 20 | - bin/** 21 | TargetRubyVersion: 3.2 22 | 23 | Gemspec/DevelopmentDependencies: 24 | Enabled: true 25 | EnforcedStyle: Gemfile 26 | 27 | Metrics/ParameterLists: 28 | Enabled: false 29 | 30 | # This cop is annoying with typed configuration 31 | Style/TrivialAccessors: 32 | Enabled: false 33 | 34 | # This rubocop is annoying when we use interfaces a lot 35 | Lint/UnusedMethodArgument: 36 | Enabled: false 37 | 38 | Gemspec/RequireMFA: 39 | Enabled: false 40 | 41 | Lint/DuplicateBranch: 42 | Enabled: false 43 | 44 | # If is sometimes easier to think about than unless sometimes 45 | Style/NegatedIf: 46 | Enabled: false 47 | 48 | # Disabling for now until it's clearer why we want this 49 | Style/FrozenStringLiteralComment: 50 | Enabled: false 51 | 52 | # It's nice to be able to read the condition first before reading the code within the condition 53 | Style/GuardClause: 54 | Enabled: false 55 | 56 | # 57 | # Leaving length metrics to human judgment for now 58 | # 59 | Metrics/ModuleLength: 60 | Enabled: false 61 | 62 | Layout/LineLength: 63 | Enabled: false 64 | 65 | Metrics/BlockLength: 66 | Enabled: false 67 | 68 | Metrics/MethodLength: 69 | Enabled: false 70 | 71 | Metrics/AbcSize: 72 | Enabled: false 73 | 74 | Metrics/ClassLength: 75 | Enabled: false 76 | 77 | # This doesn't feel useful 78 | Metrics/CyclomaticComplexity: 79 | Enabled: false 80 | 81 | # This doesn't feel useful 82 | Metrics/PerceivedComplexity: 83 | Enabled: false 84 | 85 | # It's nice to be able to read the condition first before reading the code within the condition 86 | Style/IfUnlessModifier: 87 | Enabled: false 88 | 89 | # This leads to code that is not very readable at times (very long lines) 90 | Style/ConditionalAssignment: 91 | Enabled: false 92 | 93 | # For now, we prefer to lean on clean method signatures as documentation. We may change this later. 94 | Style/Documentation: 95 | Enabled: false 96 | 97 | # Sometimes we leave comments in empty else statements intentionally 98 | Style/EmptyElse: 99 | Enabled: false 100 | 101 | # Sometimes we want to more explicitly list out a condition 102 | Style/RedundantCondition: 103 | Enabled: false 104 | 105 | # This leads to code that is not very readable at times (very long lines) 106 | Layout/MultilineMethodCallIndentation: 107 | Enabled: false 108 | 109 | # Blocks across lines are okay sometimes 110 | Style/BlockDelimiters: 111 | Enabled: false 112 | 113 | Style/StringLiterals: 114 | Enabled: false 115 | 116 | # Sometimes we like methods like `get_packages` 117 | Naming/AccessorMethodName: 118 | Enabled: false 119 | 120 | # This leads to code that is not very readable at times (very long lines) 121 | Layout/FirstArgumentIndentation: 122 | Enabled: false 123 | 124 | # This leads to code that is not very readable at times (very long lines) 125 | Layout/ArgumentAlignment: 126 | Enabled: false 127 | 128 | Style/AccessorGrouping: 129 | Enabled: false 130 | 131 | Style/HashSyntax: 132 | Enabled: false 133 | 134 | RSpec/MultipleExpectations: 135 | Enabled: false 136 | 137 | RSpec/ExampleLength: 138 | Enabled: false 139 | 140 | RSpec/ContextWording: 141 | Enabled: false 142 | -------------------------------------------------------------------------------- /lib/code_teams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # typed: strict 4 | 5 | require 'yaml' 6 | require 'pathname' 7 | require 'sorbet-runtime' 8 | require 'code_teams/plugin' 9 | require 'code_teams/plugins/identity' 10 | require 'code_teams/utils' 11 | 12 | module CodeTeams 13 | extend T::Sig 14 | 15 | class IncorrectPublicApiUsageError < StandardError; end 16 | class TeamNotFoundError < StandardError; end 17 | 18 | UNKNOWN_TEAM_STRING = 'Unknown Team' 19 | @plugins_registered = T.let(false, T::Boolean) 20 | 21 | sig { returns(T::Array[Team]) } 22 | def self.all 23 | @all ||= T.let(for_directory('config/teams'), T.nilable(T::Array[Team])) 24 | end 25 | 26 | sig { params(name: String).returns(T.nilable(Team)) } 27 | def self.find(name) 28 | @index_by_name ||= T.let(all.to_h { |t| [t.name, t] }, T.nilable(T::Hash[String, CodeTeams::Team])) 29 | @index_by_name[name] 30 | end 31 | 32 | sig { params(name: String).returns(Team) } 33 | def self.find!(name) 34 | find(name) || raise(TeamNotFoundError, "No team found with name: #{name}") 35 | end 36 | 37 | sig { params(dir: String).returns(T::Array[Team]) } 38 | def self.for_directory(dir) 39 | unless @plugins_registered 40 | Team.register_plugins 41 | @plugins_registered = true 42 | end 43 | 44 | Pathname.new(dir).glob('**/*.yml').map do |path| 45 | Team.from_yml(path.to_s) 46 | rescue Psych::SyntaxError 47 | raise IncorrectPublicApiUsageError, "The YML in #{path} has a syntax error!" 48 | end 49 | end 50 | 51 | sig { params(teams: T::Array[Team]).returns(T::Array[String]) } 52 | def self.validation_errors(teams) 53 | Plugin.all_plugins.flat_map do |plugin| 54 | plugin.validation_errors(teams) 55 | end 56 | end 57 | 58 | sig { params(string: String).returns(String) } 59 | def self.tag_value_for(string) 60 | string.tr('&', ' ').gsub(/\s+/, '_').downcase 61 | end 62 | 63 | # Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change. 64 | # Namely, the YML files that are the source of truth for teams should not change, so we should not need to look at the YMLs again to verify. 65 | # 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 66 | sig { void } 67 | def self.bust_caches! 68 | @plugins_registered = false 69 | Plugin.bust_caches! 70 | @all = nil 71 | @index_by_name = nil 72 | end 73 | 74 | class Team 75 | extend T::Sig 76 | 77 | sig { params(config_yml: String).returns(Team) } 78 | def self.from_yml(config_yml) 79 | hash = YAML.load_file(config_yml) 80 | 81 | new( 82 | config_yml: config_yml, 83 | raw_hash: hash 84 | ) 85 | end 86 | 87 | sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(Team) } 88 | def self.from_hash(raw_hash) 89 | new( 90 | config_yml: nil, 91 | raw_hash: raw_hash 92 | ) 93 | end 94 | 95 | sig { void } 96 | def self.register_plugins 97 | Plugin.all_plugins.each do |plugin| 98 | # e.g., def github (on Team) 99 | define_method(plugin.data_accessor_name) do 100 | # e.g., MyGithubPlugin.for(team).github 101 | plugin.for(T.cast(self, Team)).public_send(plugin.data_accessor_name) 102 | end 103 | end 104 | end 105 | 106 | sig { returns(T::Hash[T.untyped, T.untyped]) } 107 | attr_reader :raw_hash 108 | 109 | sig { returns(T.nilable(String)) } 110 | attr_reader :config_yml 111 | 112 | sig do 113 | params( 114 | config_yml: T.nilable(String), 115 | raw_hash: T::Hash[T.untyped, T.untyped] 116 | ).void 117 | end 118 | def initialize(config_yml:, raw_hash:) 119 | @config_yml = config_yml 120 | @raw_hash = raw_hash 121 | end 122 | 123 | sig { returns(String) } 124 | def name 125 | Plugins::Identity.for(self).identity.name 126 | end 127 | 128 | sig { returns(String) } 129 | def to_tag 130 | CodeTeams.tag_value_for(name) 131 | end 132 | 133 | sig { params(other: Object).returns(T::Boolean) } 134 | def ==(other) 135 | if other.is_a?(CodeTeams::Team) 136 | name == other.name 137 | else 138 | false 139 | end 140 | end 141 | 142 | alias eql? == 143 | 144 | sig { returns(Integer) } 145 | def hash 146 | name.hash 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at rubyatscale@gusto.com. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series of 85 | actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or permanent 92 | ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within the 112 | community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.1, available at 118 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 125 | [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 129 | [Mozilla CoC]: https://github.com/mozilla/diversity 130 | [FAQ]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | --------------------------------------------------------------------------------