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 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
@@ -0,0 +1,2 @@
1
+ --dir
2
+ .
@@ -0,0 +1,5 @@
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
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: []