Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
e9a91fb
Add rspec binstub
joemsak May 27, 2025
da7338f
Add happy path of Plugin auto-helper-method
joemsak May 27, 2025
6485514
Test nested data
joemsak May 28, 2025
4878f96
Reset registered plugins during bust_caches
joemsak May 28, 2025
ef9b6be
Support all public instance methods on a given plugin
joemsak May 28, 2025
c17a9a5
Move test setup helpers into shared support with proper mixin config
joemsak May 28, 2025
e598d45
Remove Utils, not needed now
joemsak May 28, 2025
849662c
Revert "Remove Utils, not needed now"
joemsak May 28, 2025
6c17d08
Cannot support secondary methods yet
joemsak May 28, 2025
8e2f67c
Add sorbet def
joemsak May 28, 2025
69b298b
Add Utils.demodulize
joemsak May 28, 2025
586b8f6
Add TODO for overriding Plugin.root_key
joemsak May 28, 2025
46d3154
Add root_key overriding with declaration method
joemsak May 28, 2025
2a23305
Bump version 1.0.2 -> 1.1.0
joemsak May 28, 2025
323720e
Rename root_key to data_accessor_name
joemsak May 28, 2025
7516410
Add integration for data_accessor_name override
joemsak May 28, 2025
6e062fe
DRY up the integration test
joemsak May 28, 2025
16adda5
Update README
joemsak May 28, 2025
da563a9
Formatting
joemsak May 28, 2025
605df12
README formatting
joemsak May 28, 2025
83fbcc6
README formatting
joemsak May 28, 2025
38dc8c8
README formatting
joemsak May 28, 2025
c7f1052
Update README.md
joemsak May 28, 2025
daeb9b4
Add backward-compatibility integration spec
joemsak May 28, 2025
ede535d
PR feedback
joemsak May 29, 2025
07444d3
Markdown indentation
joemsak May 29, 2025
c4cced2
refactor: use longform definition of modules over shorthand
vburzynski May 29, 2025
7e18f42
style: fix rubocop Style/ModuleFunction issue
vburzynski May 29, 2025
9b23a7b
Update spec/integration/plugin_helper_spec.rb
joemsak May 29, 2025
834ea3b
Update code_teams.rb
joemsak May 30, 2025
c640b92
Update code_teams.rb
joemsak May 30, 2025
99ec754
Update code_teams.rb
joemsak May 30, 2025
d9e2bfd
Update plugin.rb
joemsak May 30, 2025
8ffb2a0
Update .rubocop.yml
joemsak May 30, 2025
070c7a0
Update plugin_helper_spec.rb
joemsak May 30, 2025
16fe1a7
Fix rubocop target version, io helpers, helper usage
joemsak May 30, 2025
f18d7b6
Ruby 3.2 maintenance
joemsak May 30, 2025
5ce0dca
Sorbet issues fixed
joemsak May 30, 2025
7384e03
Update the bundle
joemsak Jun 2, 2025
a29287e
Add tapioca and ignore vendor/bundle
joemsak Jun 2, 2025
0f4d661
Rubocop
joemsak Jun 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/doc/
/pkg/
/spec/reports/
/vendor/bundle
/tmp/

# rspec failure tracking
Expand Down
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 35 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions bin/rspec
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion code_teams.gemspec
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
19 changes: 19 additions & 0 deletions lib/code_teams.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
require 'sorbet-runtime'
require 'code_teams/plugin'
require 'code_teams/plugins/identity'
require 'code_teams/utils'

module CodeTeams
extend T::Sig

class IncorrectPublicApiUsageError < StandardError; end

UNKNOWN_TEAM_STRING = 'Unknown Team'
@plugins_registered = T.let(false, T::Boolean)

sig { returns(T::Array[Team]) }
def self.all
Expand 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
Expand All @@ -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
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions lib/code_teams/plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions lib/code_teams/utils.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions sorbet/rbi/todo.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
# typed: strong
module ::RSpec; end
module ::TestPlugin; end
module TestNamespace::TestPlugin; end
63 changes: 63 additions & 0 deletions spec/code_teams/plugin_helper_integration_spec.rb
Original file line number Diff line number Diff line change
@@ -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
50 changes: 30 additions & 20 deletions spec/lib/code_teams/plugin_spec.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 2 additions & 6 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Loading