code_teams 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []