code_ownership 2.0.0-arm64-darwin

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: 6dccf7af0dc4e4f80d30b5c4e60c845c3005fcc9d5b0de64fa745e8c0af25c47
4
+ data.tar.gz: 06d40270b78ec948983d041ba695226f497cc5b86fde83ae24270515a78a821d
5
+ SHA512:
6
+ metadata.gz: 9d9af6e0d695787d86ea0dc075e083dff170c9d8cc6a4ff4e5116605af4689af0ff941b7745db2ccd10a414bf2f6240b952f2212557ae3ef03c98badf3b17d6f
7
+ data.tar.gz: 540277ea9d1e9efccb5658b29154ce9d9778314354bafcedebcb27f3f5a2a2c666728a2f8cd9f364fd87c077c9e108b8076aaf8fa0a683e5c665c0198ae45044
data/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # CodeOwnership
2
+
3
+ This gem helps engineering teams declare ownership of code. This gem works best in large, usually monolithic code bases where many teams work together.
4
+
5
+ Check out [`lib/code_ownership.rb`](https://github.com/rubyatscale/code_ownership/blob/main/lib/code_ownership.rb) to see the public API.
6
+
7
+ Check out [`code_ownership_spec.rb`](https://github.com/rubyatscale/code_ownership/blob/main/spec/lib/code_ownership_spec.rb) to see examples of how code ownership is used.
8
+
9
+ There is also a [companion VSCode Extension]([url](https://github.com/rubyatscale/code-ownership-vscode)) for this gem. Just search `Gusto.code-ownership-vscode` in the VSCode Extension Marketplace.
10
+
11
+ ## Getting started
12
+
13
+ To get started there's a few things you should do.
14
+
15
+ 1) Create a `config/code_ownership.yml` file and declare where your files live. Here's a sample to start with:
16
+ ```yml
17
+ owned_globs:
18
+ - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
19
+ js_package_paths: []
20
+ unowned_globs:
21
+ - db/**/*
22
+ - app/services/some_file1.rb
23
+ - app/services/some_file2.rb
24
+ - frontend/javascripts/**/__generated__/**/*
25
+ ```
26
+ 2) Declare some teams. Here's an example, that would live at `config/teams/operations.yml`:
27
+ ```yml
28
+ name: Operations
29
+ github:
30
+ team: '@my-org/operations-team'
31
+ ```
32
+ 3) Declare ownership. You can do this at a directory level or at a file level. All of the files within the `owned_globs` you declared in step 1 will need to have an owner assigned (or be opted out via `unowned_globs`). See the next section for more detail.
33
+ 4) Run validations when you commit, and/or in CI. If you run validations in CI, ensure that if your `.github/CODEOWNERS` file gets changed, that gets pushed to the PR.
34
+
35
+ ## Usage: Declaring Ownership
36
+
37
+ There are five ways to declare code ownership using this gem:
38
+
39
+ ### Directory-Based Ownership
40
+ Directory based ownership allows for all files in that directory and all its sub-directories to be owned by one team. To define this, add a `.codeowner` file inside that directory with the name of the team as the contents of that file.
41
+ ```
42
+ Team
43
+ ```
44
+
45
+ ### File-Annotation Based Ownership
46
+ File annotations are a last resort if there is no clear home for your code. File annotations go at the top of your file, and look like this:
47
+ ```ruby
48
+ # @team MyTeam
49
+ ```
50
+
51
+ ### Package-Based Ownership
52
+ Package based ownership integrates [`packwerk`](https://github.com/Shopify/packwerk) and has ownership defined per package. To define that all files within a package are owned by one team, configure your `package.yml` like this:
53
+ ```yml
54
+ enforce_dependency: true
55
+ enforce_privacy: true
56
+ metadata:
57
+ owner: Team
58
+ ```
59
+
60
+ You can also define `owner` as a top-level key, e.g.
61
+ ```yml
62
+ enforce_dependency: true
63
+ enforce_privacy: true
64
+ owner: Team
65
+ ```
66
+
67
+ To do this, add `code_ownership` to the `require` key of your `packwerk.yml`. See https://github.com/Shopify/packwerk/blob/main/USAGE.md#loading-extensions for more information.
68
+
69
+ ### Glob-Based Ownership
70
+ In your team's configured YML (see [`code_teams`](https://github.com/rubyatscale/code_teams)), you can set `owned_globs` to be a glob of files your team owns. For example, in `my_team.yml`:
71
+ ```yml
72
+ name: My Team
73
+ owned_globs:
74
+ - app/services/stuff_belonging_to_my_team/**/**
75
+ - app/controllers/other_stuff_belonging_to_my_team/**/**
76
+ unowned_globs:
77
+ - app/controllers/other_stuff_belonging_to_my_team/that_one_weird_dir_we_dont_own/*
78
+ ```
79
+
80
+ ### Javascript Package Ownership
81
+ Javascript package based ownership allows you to specify an ownership key in a `package.json`. To use this, configure your `package.json` like this:
82
+
83
+ ```json
84
+ {
85
+ // other keys
86
+ "metadata": {
87
+ "owner": "My Team"
88
+ }
89
+ // other keys
90
+ }
91
+ ```
92
+
93
+ You can also tell `code_ownership` where to find JS packages in the configuration, like this:
94
+ ```yml
95
+ js_package_paths:
96
+ - frontend/javascripts/packages/*
97
+ - frontend/other_location_for_packages/*
98
+ ```
99
+
100
+ This defaults `**/`, which makes it look for `package.json` files across your application.
101
+
102
+ > [!NOTE]
103
+ > Javscript package ownership does not respect `unowned_globs`. If you wish to disable usage of this feature you can set `js_package_paths` to an empty list.
104
+ ```yml
105
+ js_package_paths: []
106
+ ```
107
+
108
+ ## Usage: Reading CodeOwnership
109
+ ### `for_file`
110
+ `CodeOwnership.for_file`, given a relative path to a file returns a `CodeTeams::Team` if there is a team that owns the file, `nil` otherwise.
111
+
112
+ ```ruby
113
+ CodeOwnership.for_file('path/to/file/relative/to/application/root.rb')
114
+ ```
115
+
116
+ Contributor note: If you are making updates to this method or the methods getting used here, please benchmark the performance of the new implementation against the current for both `for_files` and `for_file` (with 1, 100, 1000 files).
117
+
118
+ See `code_ownership_spec.rb` for examples.
119
+
120
+ ### `for_backtrace`
121
+ `CodeOwnership.for_backtrace` can be given a backtrace and will either return `nil`, or a `CodeTeams::Team`.
122
+
123
+ ```ruby
124
+ CodeOwnership.for_backtrace(exception.backtrace)
125
+ ```
126
+
127
+ This will go through the backtrace, and return the first found owner of the files associated with frames within the backtrace.
128
+
129
+ See `code_ownership_spec.rb` for an example.
130
+
131
+ ### `for_class`
132
+
133
+ `CodeOwnership.for_class` can be given a class and will either return `nil`, or a `CodeTeams::Team`.
134
+
135
+ ```ruby
136
+ CodeOwnership.for_class(MyClass)
137
+ ```
138
+
139
+ Under the hood, this finds the file where the class is defined and returns the owner of that file.
140
+
141
+ See `code_ownership_spec.rb` for an example.
142
+
143
+ ### `for_team`
144
+ `CodeOwnership.for_team` can be used to generate an ownership report for a team.
145
+ ```ruby
146
+ CodeOwnership.for_team('My Team')
147
+ ```
148
+
149
+ You can shovel this into a markdown file for easy viewing using the CLI:
150
+ ```
151
+ bin/codeownership for_team 'My Team' > tmp/ownership_report.md
152
+ ```
153
+
154
+ ## Usage: Generating a `CODEOWNERS` file
155
+
156
+ A `CODEOWNERS` file defines who owns specific files or paths in a repository. When you run `bin/codeownership validate`, a `.github/CODEOWNERS` file will automatically be generated and updated.
157
+
158
+ If `codeowners_path` is set in `code_ownership.yml` codeowners will use that path to generate the `CODEOWNERS` file. For example, `codeowners_path: docs` will generate `docs/CODEOWNERS`.
159
+
160
+ ## Proper Configuration & Validation
161
+
162
+ CodeOwnership comes with a validation function to ensure the following things are true:
163
+
164
+ 1) Only one mechanism is defining file ownership. That is -- you can't have a file annotation on a file owned via package-based or glob-based ownership. This helps make ownership behavior more clear by avoiding concerns about precedence.
165
+ 2) All teams referenced as an owner for any file or package is a valid team (i.e. it's in the list of `CodeTeams.all`).
166
+ 3) All files have ownership. You can specify in `unowned_globs` to represent a TODO list of files to add ownership to.
167
+ 3) The `.github/CODEOWNERS` file is up to date. This is automatically corrected and staged unless specified otherwise with `bin/codeownership validate --skip-autocorrect --skip-stage`. You can turn this validation off by setting `skip_codeowners_validation: true` in `config/code_ownership.yml`.
168
+
169
+ CodeOwnership also allows you to specify which globs and file extensions should be considered ownable.
170
+
171
+ Here is an example `config/code_ownership.yml`.
172
+ ```yml
173
+ owned_globs:
174
+ - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
175
+ unowned_globs:
176
+ - db/**/*
177
+ - app/services/some_file1.rb
178
+ - app/services/some_file2.rb
179
+ - frontend/javascripts/**/__generated__/**/*
180
+ ```
181
+ You can call the validation function with the Ruby API
182
+ ```ruby
183
+ CodeOwnership.validate!
184
+ ```
185
+ or the CLI
186
+ ```
187
+ bin/codeownership validate
188
+ ```
189
+
190
+ ## Development
191
+
192
+ Please add to `CHANGELOG.md` and this `README.md` when you make make changes.
193
+
194
+ ## Running specs
195
+ ```sh
196
+ bundle install
197
+ bundle exec rake
198
+ ```
199
+
200
+ ## Creating a new release
201
+ Simply [create a new release](https://github.com/rubyatscale/code_ownership/releases/new) with github. The release tag must match the gem version
data/bin/codeownership ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # typed: strict
3
+
4
+ require 'code_ownership'
5
+ CodeOwnership::Cli.run!(ARGV)
@@ -0,0 +1,137 @@
1
+ # typed: true
2
+
3
+ require 'optparse'
4
+ require 'pathname'
5
+ require 'fileutils'
6
+
7
+ module CodeOwnership
8
+ class Cli
9
+ EXECUTABLE = 'bin/codeownership'
10
+
11
+ def self.run!(argv)
12
+ command = argv.shift
13
+ if command == 'validate'
14
+ validate!(argv)
15
+ elsif command == 'for_file'
16
+ for_file(argv)
17
+ elsif command == 'for_team'
18
+ for_team(argv)
19
+ elsif command == 'version'
20
+ version
21
+ elsif [nil, 'help'].include?(command)
22
+ puts <<~USAGE
23
+ Usage: #{EXECUTABLE} <subcommand>
24
+
25
+ Subcommands:
26
+ validate - run all validations
27
+ for_file - find code ownership for a single file
28
+ for_team - find code ownership information for a team
29
+ help - display help information about code_ownership
30
+ USAGE
31
+ else
32
+ puts "'#{command}' is not a code_ownership command. See `#{EXECUTABLE} help`."
33
+ end
34
+ end
35
+
36
+ def self.validate!(argv)
37
+ options = {}
38
+
39
+ parser = OptionParser.new do |opts|
40
+ opts.banner = "Usage: #{EXECUTABLE} validate [options]"
41
+
42
+ opts.on('--skip-autocorrect', 'Skip automatically correcting any errors, such as the .github/CODEOWNERS file') do
43
+ options[:skip_autocorrect] = true
44
+ end
45
+
46
+ opts.on('-d', '--diff', 'Only run validations with staged files') do
47
+ options[:diff] = true
48
+ end
49
+
50
+ opts.on('-s', '--skip-stage', 'Skips staging the CODEOWNERS file') do
51
+ options[:skip_stage] = true
52
+ end
53
+
54
+ opts.on('--help', 'Shows this prompt') do
55
+ puts opts
56
+ exit
57
+ end
58
+ end
59
+ args = parser.order!(argv)
60
+ parser.parse!(args)
61
+
62
+ files = if options[:diff]
63
+ ENV.fetch('CODEOWNERS_GIT_STAGED_FILES') { `git diff --staged --name-only` }.split("\n").select do |file|
64
+ File.exist?(file)
65
+ end
66
+ else
67
+ nil
68
+ end
69
+
70
+ CodeOwnership.validate!(
71
+ files: files,
72
+ autocorrect: !options[:skip_autocorrect],
73
+ stage_changes: !options[:skip_stage]
74
+ )
75
+ end
76
+
77
+ def self.version
78
+ puts CodeOwnership.version.join("\n")
79
+ end
80
+
81
+ # For now, this just returns team ownership
82
+ # Later, this could also return code ownership errors about that file.
83
+ def self.for_file(argv)
84
+ options = {}
85
+
86
+ # Long-term, we probably want to use something like `thor` so we don't have to implement logic
87
+ # like this. In the short-term, this is a simple way for us to use the built-in OptionParser
88
+ # while having an ergonomic CLI.
89
+ files = argv.reject { |arg| arg.start_with?('--') }
90
+
91
+ parser = OptionParser.new do |opts|
92
+ opts.banner = "Usage: #{EXECUTABLE} for_file [options]"
93
+
94
+ opts.on('--json', 'Output as JSON') do
95
+ options[:json] = true
96
+ end
97
+
98
+ opts.on('--verbose', 'Output verbose information') do
99
+ options[:verbose] = true
100
+ end
101
+
102
+ opts.on('--help', 'Shows this prompt') do
103
+ puts opts
104
+ exit
105
+ end
106
+ end
107
+ args = parser.order!(argv)
108
+ parser.parse!(args)
109
+
110
+ if files.count != 1
111
+ raise "Please pass in one file. Use `#{EXECUTABLE} for_file --help` for more info"
112
+ end
113
+
114
+ puts CodeOwnership::Private::ForFileOutputBuilder.build(file_path: files.first, json: !!options[:json], verbose: !!options[:verbose])
115
+ end
116
+
117
+ def self.for_team(argv)
118
+ parser = OptionParser.new do |opts|
119
+ opts.banner = "Usage: #{EXECUTABLE} for_team 'Team Name'"
120
+
121
+ opts.on('--help', 'Shows this prompt') do
122
+ puts opts
123
+ exit
124
+ end
125
+ end
126
+ teams = argv.reject { |arg| arg.start_with?('--') }
127
+ args = parser.order!(argv)
128
+ parser.parse!(args)
129
+
130
+ if teams.count != 1
131
+ raise "Please pass in one team. Use `#{EXECUTABLE} for_team --help` for more info"
132
+ end
133
+
134
+ puts CodeOwnership.for_team(teams.first).join("\n")
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module CodeOwnership
6
+ module Private
7
+ module FilePathFinder
8
+ module_function
9
+
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ # Returns a string version of the relative path to a Rails constant,
14
+ # or nil if it can't find anything
15
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(String)) }
16
+ def path_from_klass(klass)
17
+ if klass
18
+ path = Object.const_source_location(klass.to_s)&.first
19
+ (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil
20
+ end
21
+ rescue NameError
22
+ nil
23
+ end
24
+
25
+ sig { params(backtrace: T.nilable(T::Array[String])).returns(T::Enumerable[String]) }
26
+ def from_backtrace(backtrace)
27
+ return [] unless backtrace
28
+
29
+ # The pattern for a backtrace hasn't changed in forever and is considered
30
+ # stable: https://github.com/ruby/ruby/blob/trunk/vm_backtrace.c#L303-L317
31
+ #
32
+ # This pattern matches a line like the following:
33
+ #
34
+ # ./app/controllers/some_controller.rb:43:in `block (3 levels) in create'
35
+ #
36
+ backtrace_line = if RUBY_VERSION >= '3.4.0'
37
+ %r{\A(#{Pathname.pwd}/|\./)?
38
+ (?<file>.+) # Matches 'app/controllers/some_controller.rb'
39
+ :
40
+ (?<line>\d+) # Matches '43'
41
+ :in\s
42
+ '(?<function>.*)' # Matches "`block (3 levels) in create'"
43
+ \z}x
44
+ else
45
+ %r{\A(#{Pathname.pwd}/|\./)?
46
+ (?<file>.+) # Matches 'app/controllers/some_controller.rb'
47
+ :
48
+ (?<line>\d+) # Matches '43'
49
+ :in\s
50
+ `(?<function>.*)' # Matches "`block (3 levels) in create'"
51
+ \z}x
52
+ end
53
+
54
+ backtrace.lazy.filter_map do |line|
55
+ match = line.match(backtrace_line)
56
+ next unless match
57
+
58
+ T.must(match[:file])
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module CodeOwnership
6
+ module Private
7
+ module FilePathTeamCache
8
+ module_function
9
+
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) }
14
+ def get(file_path)
15
+ cache[file_path]
16
+ end
17
+
18
+ sig { params(file_path: String, team: T.nilable(CodeTeams::Team)).void }
19
+ def set(file_path, team)
20
+ cache[file_path] = team
21
+ end
22
+
23
+ sig { params(file_path: String).returns(T::Boolean) }
24
+ def cached?(file_path)
25
+ cache.key?(file_path)
26
+ end
27
+
28
+ sig { void }
29
+ def bust_cache!
30
+ @cache = nil
31
+ end
32
+
33
+ sig { returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
34
+ def cache
35
+ @cache ||= T.let(@cache,
36
+ T.nilable(T::Hash[String, T.nilable(CodeTeams::Team)]))
37
+ @cache ||= {}
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module CodeOwnership
6
+ module Private
7
+ class ForFileOutputBuilder
8
+ extend T::Sig
9
+ private_class_method :new
10
+
11
+ sig { params(file_path: String, json: T::Boolean, verbose: T::Boolean).void }
12
+ def initialize(file_path:, json:, verbose:)
13
+ @file_path = file_path
14
+ @json = json
15
+ @verbose = verbose
16
+ end
17
+
18
+ sig { params(file_path: String, json: T::Boolean, verbose: T::Boolean).returns(String) }
19
+ def self.build(file_path:, json:, verbose:)
20
+ new(file_path: file_path, json: json, verbose: verbose).build
21
+ end
22
+
23
+ UNOWNED_OUTPUT = T.let(
24
+ {
25
+ team_name: 'Unowned',
26
+ team_yml: 'Unowned'
27
+ },
28
+ T::Hash[Symbol, T.untyped]
29
+ )
30
+
31
+ sig { returns(String) }
32
+ def build
33
+ result_hash = @verbose ? build_verbose : build_terse
34
+
35
+ return result_hash.to_json if @json
36
+
37
+ build_message_for(result_hash)
38
+ end
39
+
40
+ private
41
+
42
+ sig { returns(T::Hash[Symbol, T.untyped]) }
43
+ def build_verbose
44
+ result = CodeOwnership.for_file_verbose(@file_path)
45
+ return UNOWNED_OUTPUT if result.nil?
46
+
47
+ {
48
+ team_name: result[:team_name],
49
+ team_yml: result[:team_config_yml],
50
+ description: result[:reasons]
51
+ }
52
+ end
53
+
54
+ sig { returns(T::Hash[Symbol, T.untyped]) }
55
+ def build_terse
56
+ team = CodeOwnership.for_file(@file_path, from_codeowners: false, allow_raise: true)
57
+
58
+ if team.nil?
59
+ UNOWNED_OUTPUT
60
+ else
61
+ {
62
+ team_name: team.name,
63
+ team_yml: team.config_yml
64
+ }
65
+ end
66
+ end
67
+
68
+ sig { params(result_hash: T::Hash[Symbol, T.untyped]).returns(String) }
69
+ def build_message_for(result_hash)
70
+ messages = ["Team: #{result_hash[:team_name]}", "Team YML: #{result_hash[:team_yml]}"]
71
+ description_list = T.let(Array(result_hash[:description]), T::Array[String])
72
+ messages << build_description_message(description_list) unless description_list.empty?
73
+ messages.last << "\n"
74
+ messages.join("\n")
75
+ end
76
+
77
+ sig { params(reasons: T::Array[String]).returns(String) }
78
+ def build_description_message(reasons)
79
+ "Description:\n- #{reasons.join("\n-")}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'packwerk'
5
+
6
+ module CodeOwnership
7
+ module Private
8
+ class PackOwnershipValidator
9
+ extend T::Sig
10
+ include Packwerk::Validator
11
+
12
+ sig { override.params(package_set: Packwerk::PackageSet, configuration: Packwerk::Configuration).returns(Result) }
13
+ def call(package_set, configuration)
14
+ Result.new(ok: true)
15
+ end
16
+
17
+ sig { override.returns(T::Array[String]) }
18
+ def permitted_keys
19
+ %w[owner]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module CodeOwnership
6
+ module Private
7
+ module TeamFinder
8
+ module_function
9
+
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ requires_ancestor { Kernel }
14
+
15
+ sig { params(file_path: String, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) }
16
+ def for_file(file_path, allow_raise: false)
17
+ return nil if file_path.start_with?('./')
18
+
19
+ return FilePathTeamCache.get(file_path) if FilePathTeamCache.cached?(file_path)
20
+
21
+ result = T.let(RustCodeOwners.for_file(file_path), T.nilable(T::Hash[Symbol, String]))
22
+ return if result.nil?
23
+
24
+ if result[:team_name].nil?
25
+ FilePathTeamCache.set(file_path, nil)
26
+ else
27
+ FilePathTeamCache.set(file_path, T.let(find_team!(T.must(result[:team_name]), allow_raise: allow_raise), T.nilable(CodeTeams::Team)))
28
+ end
29
+
30
+ FilePathTeamCache.get(file_path)
31
+ end
32
+
33
+ sig { params(files: T::Array[String], allow_raise: T::Boolean).returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
34
+ def teams_for_files(files, allow_raise: false)
35
+ result = {}
36
+
37
+ # Collect cached results and identify non-cached files
38
+ not_cached_files = []
39
+ files.each do |file_path|
40
+ if FilePathTeamCache.cached?(file_path)
41
+ result[file_path] = FilePathTeamCache.get(file_path)
42
+ else
43
+ not_cached_files << file_path
44
+ end
45
+ end
46
+
47
+ return result if not_cached_files.empty?
48
+
49
+ # Process non-cached files
50
+ ::RustCodeOwners.teams_for_files(not_cached_files).each do |path_team|
51
+ file_path, team = path_team
52
+ found_team = team ? find_team!(team[:team_name], allow_raise: allow_raise) : nil
53
+ FilePathTeamCache.set(file_path, found_team)
54
+ result[file_path] = found_team
55
+ end
56
+
57
+ result
58
+ end
59
+
60
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) }
61
+ def for_class(klass)
62
+ file_path = FilePathFinder.path_from_klass(klass)
63
+ return nil if file_path.nil?
64
+
65
+ for_file(file_path)
66
+ end
67
+
68
+ sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) }
69
+ def for_package(package)
70
+ owner_name = package.raw_hash['owner'] || package.metadata['owner']
71
+ return nil if owner_name.nil?
72
+
73
+ find_team!(owner_name, allow_raise: true)
74
+ end
75
+
76
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) }
77
+ def for_backtrace(backtrace, excluded_teams: [])
78
+ first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)&.first
79
+ end
80
+
81
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) }
82
+ def first_owned_file_for_backtrace(backtrace, excluded_teams: [])
83
+ FilePathFinder.from_backtrace(backtrace).each do |file|
84
+ team = for_file(file)
85
+ if team && !excluded_teams.include?(team)
86
+ return [team, file]
87
+ end
88
+ end
89
+
90
+ nil
91
+ end
92
+
93
+ sig { params(team_name: String, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) }
94
+ def find_team!(team_name, allow_raise: false)
95
+ team = CodeTeams.find(team_name)
96
+ if team.nil? && allow_raise
97
+ raise(StandardError, "Could not find team with name: `#{team_name}`. Make sure the team is one of `#{CodeTeams.all.map(&:name).sort}`")
98
+ end
99
+
100
+ team
101
+ end
102
+
103
+ private_class_method(:find_team!)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,6 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module CodeOwnership
5
+ VERSION = '2.0.0'
6
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require 'set'
6
+ require 'code_teams'
7
+ require 'sorbet-runtime'
8
+ require 'json'
9
+ require 'packs-specification'
10
+ require 'code_ownership/version'
11
+ require 'code_ownership/private/file_path_finder'
12
+ require 'code_ownership/private/file_path_team_cache'
13
+ require 'code_ownership/private/team_finder'
14
+ require 'code_ownership/private/for_file_output_builder'
15
+ require 'code_ownership/cli'
16
+
17
+ begin
18
+ RUBY_VERSION =~ /(\d+\.\d+)/
19
+ require "code_ownership/#{Regexp.last_match(1)}/code_ownership"
20
+ rescue LoadError
21
+ require 'code_ownership/code_ownership'
22
+ end
23
+
24
+ if defined?(Packwerk)
25
+ require 'code_ownership/private/permit_pack_owner_top_level_key'
26
+ end
27
+
28
+ module CodeOwnership
29
+ module_function
30
+
31
+ extend T::Sig
32
+ extend T::Helpers
33
+
34
+ requires_ancestor { Kernel }
35
+ GlobsToOwningTeamMap = T.type_alias { T::Hash[String, CodeTeams::Team] }
36
+
37
+ # Returns the version of the code_ownership gem and the codeowners-rs gem.
38
+ sig { returns(T::Array[String]) }
39
+ def version
40
+ ["code_ownership version: #{VERSION}",
41
+ "codeowners-rs version: #{::RustCodeOwners.version}"]
42
+ end
43
+
44
+ # Returns the owning team for a given file path.
45
+ #
46
+ # @param file [String] The path to the file to find ownership for. Can be relative or absolute.
47
+ # @param from_codeowners [Boolean] (default: true) When true, uses CODEOWNERS file to determine ownership.
48
+ # When false, uses alternative team finding strategies (e.g., package ownership).
49
+ # from_codeowners true is faster because it simply matches the provided file to the generate CODEOWNERS file. This is a safe option when you can trust the CODEOWNERS file to be up to date.
50
+ # @param allow_raise [Boolean] (default: false) When true, raises an exception if ownership cannot be determined.
51
+ # When false, returns nil for files without ownership.
52
+ #
53
+ # @return [CodeTeams::Team, nil] The team that owns the file, or nil if no owner is found
54
+ # (unless allow_raise is true, in which case an exception is raised).
55
+ #
56
+ # @example Find owner for a file using CODEOWNERS
57
+ # team = CodeOwnership.for_file('app/models/user.rb')
58
+ # # => #<CodeTeams::Team:0x... @name="platform">
59
+ #
60
+ # @example Find owner without using CODEOWNERS
61
+ # team = CodeOwnership.for_file('app/models/user.rb', from_codeowners: false)
62
+ # # => #<CodeTeams::Team:0x... @name="platform">
63
+ #
64
+ # @example Raise if no owner is found
65
+ # team = CodeOwnership.for_file('unknown_file.rb', allow_raise: true)
66
+ # # => raises exception if no owner found
67
+ #
68
+ sig { params(file: String, from_codeowners: T::Boolean, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) }
69
+ def for_file(file, from_codeowners: true, allow_raise: false)
70
+ if from_codeowners
71
+ teams_for_files_from_codeowners([file], allow_raise: allow_raise).values.first
72
+ else
73
+ Private::TeamFinder.for_file(file, allow_raise: allow_raise)
74
+ end
75
+ end
76
+
77
+ # Returns the owning teams for multiple file paths using the CODEOWNERS file.
78
+ #
79
+ # This method efficiently determines ownership for multiple files in a single operation
80
+ # by leveraging the generated CODEOWNERS file. It's more performant than calling
81
+ # `for_file` multiple times when you need to check ownership for many files.
82
+ #
83
+ # @param files [Array<String>] An array of file paths to find ownership for.
84
+ # Paths can be relative to the project root or absolute.
85
+ # @param allow_raise [Boolean] (default: false) When true, raises an exception if a team
86
+ # name in CODEOWNERS cannot be resolved to an actual team.
87
+ # When false, returns nil for files with unresolvable teams.
88
+ #
89
+ # @return [T::Hash[String, T.nilable(CodeTeams::Team)]] A hash mapping each file path to its
90
+ # owning team. Files without ownership
91
+ # or with unresolvable teams will map to nil.
92
+ #
93
+ # @example Get owners for multiple files
94
+ # files = ['app/models/user.rb', 'app/controllers/users_controller.rb', 'config/routes.rb']
95
+ # owners = CodeOwnership.teams_for_files_from_codeowners(files)
96
+ # # => {
97
+ # # 'app/models/user.rb' => #<CodeTeams::Team:0x... @name="platform">,
98
+ # # 'app/controllers/users_controller.rb' => #<CodeTeams::Team:0x... @name="platform">,
99
+ # # 'config/routes.rb' => #<CodeTeams::Team:0x... @name="infrastructure">
100
+ # # }
101
+ #
102
+ # @example Handle files without owners
103
+ # files = ['owned_file.rb', 'unowned_file.txt']
104
+ # owners = CodeOwnership.teams_for_files_from_codeowners(files)
105
+ # # => {
106
+ # # 'owned_file.rb' => #<CodeTeams::Team:0x... @name="backend">,
107
+ # # 'unowned_file.txt' => nil
108
+ # # }
109
+ #
110
+ # @note This method uses caching internally for performance. The cache is populated
111
+ # as files are processed and reused for subsequent lookups.
112
+ #
113
+ # @note This method relies on the CODEOWNERS file being up-to-date. Run
114
+ # `CodeOwnership.validate!` to ensure the CODEOWNERS file is current.
115
+ #
116
+ # @see #for_file for single file ownership lookup
117
+ # @see #validate! for ensuring CODEOWNERS file is up-to-date
118
+ #
119
+ sig { params(files: T::Array[String], allow_raise: T::Boolean).returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
120
+ def teams_for_files_from_codeowners(files, allow_raise: false)
121
+ Private::TeamFinder.teams_for_files(files, allow_raise: allow_raise)
122
+ end
123
+
124
+ # Returns detailed ownership information for a given file path.
125
+ #
126
+ # This method provides verbose ownership details including the team name,
127
+ # team configuration file path, and the reasons/sources for ownership assignment.
128
+ # It's particularly useful for debugging ownership assignments and understanding
129
+ # why a file is owned by a specific team.
130
+ #
131
+ # @param file [String] The path to the file to find ownership for. Can be relative or absolute.
132
+ #
133
+ # @return [T::Hash[Symbol, String], nil] A hash containing detailed ownership information,
134
+ # or nil if no owner is found.
135
+ #
136
+ # The returned hash contains the following keys when an owner is found:
137
+ # - :team_name [String] - The name of the owning team
138
+ # - :team_config_yml [String] - Path to the team's configuration YAML file
139
+ # - :reasons [Array<String>] - List of reasons/sources explaining why this team owns the file
140
+ # (e.g., "CODEOWNERS pattern: /app/models/**", "Package ownership")
141
+ #
142
+ # @example Get verbose ownership details
143
+ # details = CodeOwnership.for_file_verbose('app/models/user.rb')
144
+ # # => {
145
+ # # team_name: "platform",
146
+ # # team_config_yml: "config/teams/platform.yml",
147
+ # # reasons: ["Matched pattern '/app/models/**' in CODEOWNERS"]
148
+ # # }
149
+ #
150
+ # @example Handle unowned files
151
+ # details = CodeOwnership.for_file_verbose('unowned_file.txt')
152
+ # # => nil
153
+ #
154
+ # @note This method is primarily used by the CLI tool when the --verbose flag is provided,
155
+ # allowing users to understand the ownership assignment logic.
156
+ #
157
+ # @note Unlike `for_file`, this method always uses the CODEOWNERS file and other ownership
158
+ # sources to determine ownership, providing complete context about the ownership decision.
159
+ #
160
+ # @see #for_file for a simpler ownership lookup that returns just the team
161
+ # @see CLI#for_file for the command-line interface that uses this method
162
+ #
163
+ sig { params(file: String).returns(T.nilable(T::Hash[Symbol, String])) }
164
+ def for_file_verbose(file)
165
+ ::RustCodeOwners.for_file(file)
166
+ end
167
+
168
+ sig { params(team: T.any(CodeTeams::Team, String)).returns(T::Array[String]) }
169
+ def for_team(team)
170
+ team = T.must(CodeTeams.find(team)) if team.is_a?(String)
171
+ ::RustCodeOwners.for_team(team.name)
172
+ end
173
+
174
+ # Validates code ownership configuration and optionally corrects issues.
175
+ #
176
+ # This method performs comprehensive validation of the code ownership setup, ensuring:
177
+ # 1. Only one ownership mechanism is defined per file (no conflicts between annotations, packages, or globs)
178
+ # 2. All referenced teams are valid (exist in CodeTeams configuration)
179
+ # 3. All files have ownership (unless explicitly listed in unowned_globs)
180
+ # 4. The .github/CODEOWNERS file is up-to-date and properly formatted
181
+ #
182
+ # When autocorrect is enabled, the method will automatically:
183
+ # - Generate or update the CODEOWNERS file based on current ownership rules
184
+ # - Fix any formatting issues in the CODEOWNERS file
185
+ # - Stage the corrected CODEOWNERS file (unless stage_changes is false)
186
+ #
187
+ # @param autocorrect [Boolean] Whether to automatically fix correctable issues (default: true)
188
+ # When true, regenerates and updates the CODEOWNERS file
189
+ # When false, only validates without making changes
190
+ #
191
+ # @param stage_changes [Boolean] Whether to stage the CODEOWNERS file after autocorrection (default: true)
192
+ # Only applies when autocorrect is true
193
+ # When false, changes are written but not staged with git
194
+ #
195
+ # @param files [Array<String>, nil] Ignored. This is a legacy parameter that is no longer used.
196
+ #
197
+ # @return [void]
198
+ #
199
+ # @raise [RuntimeError] Raises an error if validation fails with details about:
200
+ # - Files with conflicting ownership definitions
201
+ # - References to non-existent teams
202
+ # - Files without ownership (not in unowned_globs)
203
+ # - CODEOWNERS file inconsistencies
204
+ #
205
+ # @example Basic validation with autocorrection
206
+ # CodeOwnership.validate!
207
+ # # Validates all files and auto-corrects/stages CODEOWNERS if needed
208
+ #
209
+ # @example Validation without making changes
210
+ # CodeOwnership.validate!(autocorrect: false)
211
+ # # Only checks for issues without updating CODEOWNERS
212
+ #
213
+ # @example Validate and fix but don't stage changes
214
+ # CodeOwnership.validate!(autocorrect: true, stage_changes: false)
215
+ # # Fixes CODEOWNERS but doesn't stage it with git
216
+ #
217
+ # @note This method is called by the CLI command: bin/codeownership validate
218
+ # @note The validation can be disabled for CODEOWNERS by setting skip_codeowners_validation: true in config/code_ownership.yml
219
+ #
220
+ # @see CLI.validate! for the command-line interface
221
+ # @see https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners for CODEOWNERS format
222
+ #
223
+ sig do
224
+ params(
225
+ autocorrect: T::Boolean,
226
+ stage_changes: T::Boolean,
227
+ files: T.nilable(T::Array[String])
228
+ ).void
229
+ end
230
+ def validate!(
231
+ autocorrect: true,
232
+ stage_changes: true,
233
+ files: nil
234
+ )
235
+ if autocorrect
236
+ ::RustCodeOwners.generate_and_validate(files, !stage_changes)
237
+ else
238
+ ::RustCodeOwners.validate(files)
239
+ end
240
+ end
241
+
242
+ # Given a backtrace from either `Exception#backtrace` or `caller`, find the
243
+ # first line that corresponds to a file with assigned ownership
244
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) }
245
+ def for_backtrace(backtrace, excluded_teams: [])
246
+ Private::TeamFinder.for_backtrace(backtrace, excluded_teams: excluded_teams)
247
+ end
248
+
249
+ # Given a backtrace from either `Exception#backtrace` or `caller`, find the
250
+ # first owned file in it, useful for figuring out which file is being blamed.
251
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) }
252
+ def first_owned_file_for_backtrace(backtrace, excluded_teams: [])
253
+ Private::TeamFinder.first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)
254
+ end
255
+
256
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) }
257
+ def for_class(klass)
258
+ Private::TeamFinder.for_class(klass)
259
+ end
260
+
261
+ sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) }
262
+ def for_package(package)
263
+ Private::TeamFinder.for_package(package)
264
+ end
265
+
266
+ # Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change.
267
+ # Namely, the set of files, packages, and directories which are tracked for ownership should not change.
268
+ # The primary reason this is helpful is for clients of CodeOwnership who want to test their code, and each test context
269
+ # has different ownership and tracked files.
270
+ sig { void }
271
+ def self.bust_caches!
272
+ Private::FilePathTeamCache.bust_cache!
273
+ end
274
+ end
metadata ADDED
@@ -0,0 +1,219 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: code_ownership
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.0
5
+ platform: arm64-darwin
6
+ authors:
7
+ - Gusto Engineers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: code_teams
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: packs-specification
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
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: sorbet-runtime
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 0.5.11249
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 0.5.11249
55
+ - !ruby/object:Gem::Dependency
56
+ name: debug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: packwerk
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
+ - !ruby/object:Gem::Dependency
84
+ name: railties
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: sorbet
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: tapioca
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ description: A gem to help engineering teams declare ownership of code
168
+ email:
169
+ - dev@gusto.com
170
+ executables:
171
+ - codeownership
172
+ extensions: []
173
+ extra_rdoc_files: []
174
+ files:
175
+ - LICENSE
176
+ - README.md
177
+ - bin/codeownership
178
+ - lib/code_ownership.rb
179
+ - lib/code_ownership/3.2/code_ownership.bundle
180
+ - lib/code_ownership/3.4/code_ownership.bundle
181
+ - lib/code_ownership/cli.rb
182
+ - lib/code_ownership/private/file_path_finder.rb
183
+ - lib/code_ownership/private/file_path_team_cache.rb
184
+ - lib/code_ownership/private/for_file_output_builder.rb
185
+ - lib/code_ownership/private/permit_pack_owner_top_level_key.rb
186
+ - lib/code_ownership/private/team_finder.rb
187
+ - lib/code_ownership/version.rb
188
+ homepage: https://github.com/rubyatscale/code_ownership
189
+ licenses:
190
+ - MIT
191
+ metadata:
192
+ homepage_uri: https://github.com/rubyatscale/code_ownership
193
+ source_code_uri: https://github.com/rubyatscale/code_ownership
194
+ changelog_uri: https://github.com/rubyatscale/code_ownership/releases
195
+ allowed_push_host: https://rubygems.org
196
+ cargo_crate_name: code_ownership
197
+ post_install_message:
198
+ rdoc_options: []
199
+ require_paths:
200
+ - lib
201
+ required_ruby_version: !ruby/object:Gem::Requirement
202
+ requirements:
203
+ - - ">="
204
+ - !ruby/object:Gem::Version
205
+ version: '3.2'
206
+ - - "<"
207
+ - !ruby/object:Gem::Version
208
+ version: 3.5.dev
209
+ required_rubygems_version: !ruby/object:Gem::Requirement
210
+ requirements:
211
+ - - ">="
212
+ - !ruby/object:Gem::Version
213
+ version: '0'
214
+ requirements: []
215
+ rubygems_version: 3.5.23
216
+ signing_key:
217
+ specification_version: 4
218
+ summary: A gem to help engineering teams declare ownership of code
219
+ test_files: []