code_teams 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +92 -0
- data/lib/code_teams/plugin.rb +67 -0
- data/lib/code_teams/plugins/identity.rb +37 -0
- data/lib/code_teams.rb +131 -0
- data/sorbet/config +2 -0
- data/sorbet/rbi/todo.rbi +5 -0
- metadata +123 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 82e9081880258d807cdc9cb6393597d729e36ddb2cc0e91cc5f2179b52477d6a
|
4
|
+
data.tar.gz: ef5116c3487e50cb9987bf110e64410a5c343341db448df9a9c3400b8ee0feee
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 74a20af786913be7e7a12a5ad64c1605ddeca3ba895cb1733095d1b4762ffc26a08798073697c25d9939aa59c94174badaae70e37e7306b24b7ba2e62aac67d3
|
7
|
+
data.tar.gz: d070407e8113266e5f364c7abc3485bee7bc15bdaa5a25d46e98c5066e9ccd4561ef1b9ad9808adb0ef9df4eebf613838a74bc8d78587795b8d97bec99837b19
|
data/README.md
ADDED
@@ -0,0 +1,92 @@
|
|
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
|
+
members = github.members
|
34
|
+
return false unless members
|
35
|
+
|
36
|
+
members.include?(user)
|
37
|
+
end
|
38
|
+
|
39
|
+
sig { override.params(teams: T::Array[CodeTeams::Team]).returns(T::Array[String]) }
|
40
|
+
def self.validation_errors(teams)
|
41
|
+
errors = T.let([], T::Array[String])
|
42
|
+
|
43
|
+
teams.each do |team|
|
44
|
+
errors << missing_key_error_message(team, 'github.team') if self.for(team).github.team.nil?
|
45
|
+
end
|
46
|
+
|
47
|
+
errors
|
48
|
+
end
|
49
|
+
end
|
50
|
+
```
|
51
|
+
|
52
|
+
After adding the proper GitHub information to the team YML:
|
53
|
+
```yml
|
54
|
+
name: My Team
|
55
|
+
github:
|
56
|
+
team: '@org/my-team
|
57
|
+
members:
|
58
|
+
- member1
|
59
|
+
- member2
|
60
|
+
```
|
61
|
+
|
62
|
+
1) You can now use the following API to get GitHub information about that team:
|
63
|
+
```ruby
|
64
|
+
team = CodeTeams.find('My Team')
|
65
|
+
MyGithubPlugin.for(team).github
|
66
|
+
```
|
67
|
+
2) Running team validations (see below) will ensure all teams have a GitHub team specified
|
68
|
+
|
69
|
+
Your plugins can be as simple or as complex as you want. Here are some other things we use plugins for:
|
70
|
+
- Identifying which teams own which feature flags
|
71
|
+
- Mapping teams to specific portions of the code through `code_ownership`
|
72
|
+
- Allowing teams to protect certain files and require approval on modification of certain files
|
73
|
+
- Specifying owned dependencies (ruby gems, javascript packages, and more)
|
74
|
+
- Specifying how to get in touch with the team via slack (their channel and handle)
|
75
|
+
|
76
|
+
## Configuration
|
77
|
+
You'll want to ensure that all teams are valid in your CI environment. We recommend running code like this in CI:
|
78
|
+
```ruby
|
79
|
+
require 'code_teams'
|
80
|
+
errors = ::CodeTeams.validation_errors(::CodeTeams.all)
|
81
|
+
if errors.any?
|
82
|
+
abort <<~ERROR
|
83
|
+
Team validation failed with the following errors:
|
84
|
+
#{errors.join("\n")}
|
85
|
+
ERROR
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
## Contributing
|
90
|
+
|
91
|
+
Bug reports and pull requests are welcome!
|
92
|
+
|
@@ -0,0 +1,67 @@
|
|
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
|
+
sig { params(team: Team).void }
|
14
|
+
def initialize(team)
|
15
|
+
@team = team
|
16
|
+
end
|
17
|
+
|
18
|
+
sig { params(base: T.untyped).void }
|
19
|
+
def self.inherited(base) # rubocop:disable Lint/MissingSuper
|
20
|
+
all_plugins << T.cast(base, T.class_of(Plugin))
|
21
|
+
end
|
22
|
+
|
23
|
+
sig { returns(T::Array[T.class_of(Plugin)]) }
|
24
|
+
def self.all_plugins
|
25
|
+
@all_plugins ||= T.let(@all_plugins, T.nilable(T::Array[T.class_of(Plugin)]))
|
26
|
+
@all_plugins ||= []
|
27
|
+
@all_plugins
|
28
|
+
end
|
29
|
+
|
30
|
+
sig { params(teams: T::Array[Team]).returns(T::Array[String]) }
|
31
|
+
def self.validation_errors(teams) # rubocop:disable Lint/UnusedMethodArgument
|
32
|
+
[]
|
33
|
+
end
|
34
|
+
|
35
|
+
sig { params(team: Team).returns(T.attached_class) }
|
36
|
+
def self.for(team)
|
37
|
+
register_team(team)
|
38
|
+
end
|
39
|
+
|
40
|
+
sig { params(team: Team, key: String).returns(String) }
|
41
|
+
def self.missing_key_error_message(team, key)
|
42
|
+
"#{team.name} is missing required key `#{key}`"
|
43
|
+
end
|
44
|
+
|
45
|
+
sig { returns(T::Hash[T.nilable(String), T::Hash[Class, Plugin]]) }
|
46
|
+
def self.registry
|
47
|
+
@registry ||= T.let(@registry, T.nilable(T::Hash[String, T::Hash[Class, Plugin]]))
|
48
|
+
@registry ||= {}
|
49
|
+
@registry
|
50
|
+
end
|
51
|
+
|
52
|
+
sig { params(team: Team).returns(T.attached_class) }
|
53
|
+
def self.register_team(team)
|
54
|
+
# We pull from the hash since `team.name` uses the registry
|
55
|
+
team_name = team.raw_hash['name']
|
56
|
+
|
57
|
+
registry[team_name] ||= {}
|
58
|
+
registry_for_team = registry[team_name] || {}
|
59
|
+
registry[team_name] ||= {}
|
60
|
+
registry_for_team[self] ||= new(team)
|
61
|
+
T.unsafe(registry_for_team[self])
|
62
|
+
end
|
63
|
+
|
64
|
+
private_class_method :registry
|
65
|
+
private_class_method :register_team
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,37 @@
|
|
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
|
data/lib/code_teams.rb
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# typed: strict
|
4
|
+
|
5
|
+
require 'yaml'
|
6
|
+
require 'set'
|
7
|
+
require 'pathname'
|
8
|
+
require 'sorbet-runtime'
|
9
|
+
require 'code_teams/plugin'
|
10
|
+
require 'code_teams/plugins/identity'
|
11
|
+
|
12
|
+
module CodeTeams
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
class IncorrectPublicApiUsageError < StandardError; end
|
16
|
+
|
17
|
+
UNKNOWN_TEAM_STRING = 'Unknown Team'
|
18
|
+
|
19
|
+
sig { returns(T::Array[Team]) }
|
20
|
+
def self.all
|
21
|
+
@all = T.let(@all, T.nilable(T::Array[Team]))
|
22
|
+
@all ||= for_directory('config/teams')
|
23
|
+
end
|
24
|
+
|
25
|
+
sig { params(name: String).returns(T.nilable(Team)) }
|
26
|
+
def self.find(name)
|
27
|
+
@index_by_name = T.let(@index_by_name, T.nilable(T::Hash[String, CodeTeams::Team]))
|
28
|
+
@index_by_name ||= begin
|
29
|
+
result = {}
|
30
|
+
all.each { |t| result[t.name] = t }
|
31
|
+
result
|
32
|
+
end
|
33
|
+
|
34
|
+
@index_by_name[name]
|
35
|
+
end
|
36
|
+
|
37
|
+
sig { params(dir: String).returns(T::Array[Team]) }
|
38
|
+
def self.for_directory(dir)
|
39
|
+
Pathname.new(dir).glob('**/*.yml').map do |path|
|
40
|
+
Team.from_yml(path.to_s)
|
41
|
+
rescue Psych::SyntaxError
|
42
|
+
raise IncorrectPublicApiUsageError, "The YML in #{path} has a syntax error!"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
sig { params(teams: T::Array[Team]).returns(T::Array[String]) }
|
47
|
+
def self.validation_errors(teams)
|
48
|
+
Plugin.all_plugins.flat_map do |plugin|
|
49
|
+
plugin.validation_errors(teams)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
sig { params(string: String).returns(String) }
|
54
|
+
def self.tag_value_for(string)
|
55
|
+
string.tr('&', ' ').gsub(/\s+/, '_').downcase
|
56
|
+
end
|
57
|
+
|
58
|
+
# Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change.
|
59
|
+
# 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.
|
60
|
+
# 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
|
61
|
+
sig { void }
|
62
|
+
def self.bust_caches!
|
63
|
+
@all = nil
|
64
|
+
@index_by_name = nil
|
65
|
+
end
|
66
|
+
|
67
|
+
class Team
|
68
|
+
extend T::Sig
|
69
|
+
|
70
|
+
sig { params(config_yml: String).returns(Team) }
|
71
|
+
def self.from_yml(config_yml)
|
72
|
+
hash = YAML.load_file(config_yml)
|
73
|
+
|
74
|
+
new(
|
75
|
+
config_yml: config_yml,
|
76
|
+
raw_hash: hash
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(Team) }
|
81
|
+
def self.from_hash(raw_hash)
|
82
|
+
new(
|
83
|
+
config_yml: nil,
|
84
|
+
raw_hash: raw_hash
|
85
|
+
)
|
86
|
+
end
|
87
|
+
|
88
|
+
sig { returns(T::Hash[T.untyped, T.untyped]) }
|
89
|
+
attr_reader :raw_hash
|
90
|
+
|
91
|
+
sig { returns(T.nilable(String)) }
|
92
|
+
attr_reader :config_yml
|
93
|
+
|
94
|
+
sig do
|
95
|
+
params(
|
96
|
+
config_yml: T.nilable(String),
|
97
|
+
raw_hash: T::Hash[T.untyped, T.untyped]
|
98
|
+
).void
|
99
|
+
end
|
100
|
+
def initialize(config_yml:, raw_hash:)
|
101
|
+
@config_yml = config_yml
|
102
|
+
@raw_hash = raw_hash
|
103
|
+
end
|
104
|
+
|
105
|
+
sig { returns(String) }
|
106
|
+
def name
|
107
|
+
Plugins::Identity.for(self).identity.name
|
108
|
+
end
|
109
|
+
|
110
|
+
sig { returns(String) }
|
111
|
+
def to_tag
|
112
|
+
CodeTeams.tag_value_for(name)
|
113
|
+
end
|
114
|
+
|
115
|
+
sig { params(other: Object).returns(T::Boolean) }
|
116
|
+
def ==(other)
|
117
|
+
if other.is_a?(CodeTeams::Team)
|
118
|
+
self.name == other.name
|
119
|
+
else
|
120
|
+
false
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
alias_method :eql?, :==
|
125
|
+
|
126
|
+
sig { returns(Integer) }
|
127
|
+
def hash # rubocop:disable Rails/Delegate
|
128
|
+
name.hash
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
data/sorbet/config
ADDED
data/sorbet/rbi/todo.rbi
ADDED
metadata
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: code_teams
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Gusto Engineers
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-06-14 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: sorbet-runtime
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: pry
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sorbet
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: A low-dependency gem for declaring and querying engineering teams
|
84
|
+
email:
|
85
|
+
- dev@gusto.com
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- README.md
|
91
|
+
- lib/code_teams.rb
|
92
|
+
- lib/code_teams/plugin.rb
|
93
|
+
- lib/code_teams/plugins/identity.rb
|
94
|
+
- sorbet/config
|
95
|
+
- sorbet/rbi/todo.rbi
|
96
|
+
homepage: https://github.com/rubyatscale/code_teams
|
97
|
+
licenses:
|
98
|
+
- MIT
|
99
|
+
metadata:
|
100
|
+
homepage_uri: https://github.com/rubyatscale/code_teams
|
101
|
+
source_code_uri: https://github.com/rubyatscale/code_teams
|
102
|
+
changelog_uri: https://github.com/rubyatscale/code_teams/releases
|
103
|
+
allowed_push_host: https://rubygems.org
|
104
|
+
post_install_message:
|
105
|
+
rdoc_options: []
|
106
|
+
require_paths:
|
107
|
+
- lib
|
108
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '2.6'
|
113
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
requirements: []
|
119
|
+
rubygems_version: 3.3.7
|
120
|
+
signing_key:
|
121
|
+
specification_version: 4
|
122
|
+
summary: A low-dependency gem for declaring and querying engineering teams
|
123
|
+
test_files: []
|