code_ownership 1.23.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.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module CodeOwnership
6
+ module Private
7
+ # Modeled off of ParsePackwerk
8
+ module ParseJsPackages
9
+ extend T::Sig
10
+
11
+ ROOT_PACKAGE_NAME = 'root'
12
+ PACKAGE_JSON_NAME = T.let('package.json', String)
13
+ METADATA = 'metadata'
14
+
15
+ class Package < T::Struct
16
+ extend T::Sig
17
+
18
+ const :name, String
19
+ const :metadata, T::Hash[String, T.untyped]
20
+
21
+ sig { params(pathname: Pathname).returns(Package) }
22
+ def self.from(pathname)
23
+ package_loaded_json = JSON.parse(pathname.read)
24
+
25
+ package_name = if pathname.dirname == Pathname.new('.')
26
+ ROOT_PACKAGE_NAME
27
+ else
28
+ pathname.dirname.cleanpath.to_s
29
+ end
30
+
31
+ new(
32
+ name: package_name,
33
+ metadata: package_loaded_json[METADATA] || {}
34
+ )
35
+ end
36
+
37
+ sig { returns(Pathname) }
38
+ def directory
39
+ root_pathname = Pathname.new('.')
40
+ name == ROOT_PACKAGE_NAME ? root_pathname.cleanpath : root_pathname.join(name).cleanpath
41
+ end
42
+ end
43
+
44
+ sig do
45
+ returns(T::Array[Package])
46
+ end
47
+ def self.all
48
+ package_glob_patterns = Private.configuration.js_package_paths.map do |pathspec|
49
+ File.join(pathspec, PACKAGE_JSON_NAME)
50
+ end
51
+
52
+ # The T.unsafe is because the upstream RBI is wrong for Pathname.glob
53
+ T.unsafe(Pathname).glob(package_glob_patterns).map(&:cleanpath).map do |path|
54
+ Package.from(path)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ # typed: true
2
+
3
+ module CodeOwnership
4
+ module Private
5
+ module TeamPlugins
6
+ class Github < Teams::Plugin
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ GithubStruct = Struct.new(:team, :do_not_add_to_codeowners_file)
11
+
12
+ sig { returns(GithubStruct) }
13
+ def github
14
+ raw_github = @team.raw_hash['github'] || {}
15
+
16
+ GithubStruct.new(
17
+ raw_github['team'],
18
+ raw_github['do_not_add_to_codeowners_file'] || false
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # typed: true
2
+
3
+ module CodeOwnership
4
+ module Private
5
+ module TeamPlugins
6
+ class Ownership < Teams::Plugin
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ sig { returns(T::Array[String]) }
11
+ def owned_globs
12
+ @team.raw_hash['owned_globs'] || []
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+
3
+ module CodeOwnership
4
+ module Private
5
+ module Validations
6
+ class FilesHaveOwners
7
+ extend T::Sig
8
+ extend T::Helpers
9
+ include Interface
10
+
11
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
13
+ allow_list = Dir.glob(Private.configuration.unowned_globs)
14
+ files_by_mapper = Private.files_by_mapper(files)
15
+ files_not_mapped_at_all = files_by_mapper.select { |_file, mapper_descriptions| mapper_descriptions.count == 0 }.keys
16
+
17
+ files_without_owners = files_not_mapped_at_all - allow_list
18
+
19
+ errors = T.let([], T::Array[String])
20
+
21
+ if files_without_owners.any?
22
+ errors << <<~MSG
23
+ Some files are missing ownership:
24
+
25
+ #{files_without_owners.map { |file| "- #{file}" }.join("\n")}
26
+ MSG
27
+ end
28
+
29
+ errors
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ # typed: strict
2
+
3
+ module CodeOwnership
4
+ module Private
5
+ module Validations
6
+ class FilesHaveUniqueOwners
7
+ extend T::Sig
8
+ extend T::Helpers
9
+ include Interface
10
+
11
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
13
+ files_by_mapper = Private.files_by_mapper(files)
14
+
15
+ files_mapped_by_multiple_mappers = files_by_mapper.select { |_file, mapper_descriptions| mapper_descriptions.count > 1 }.to_h
16
+
17
+ errors = T.let([], T::Array[String])
18
+
19
+ if files_mapped_by_multiple_mappers.any?
20
+ errors << <<~MSG
21
+ Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways.
22
+
23
+ #{files_mapped_by_multiple_mappers.map { |file, descriptions| "- #{file} (#{descriptions.join(', ')})" }.join("\n")}
24
+ MSG
25
+ end
26
+
27
+ errors
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,85 @@
1
+ # typed: strict
2
+
3
+ module CodeOwnership
4
+ module Private
5
+ module Validations
6
+ class GithubCodeownersUpToDate
7
+ extend T::Sig
8
+ extend T::Helpers
9
+ include Interface
10
+
11
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
13
+ return [] if Private.configuration.skip_codeowners_validation
14
+
15
+ codeowners_filepath = Pathname.pwd.join('.github/CODEOWNERS')
16
+ FileUtils.mkdir_p(codeowners_filepath.dirname) if !codeowners_filepath.dirname.exist?
17
+
18
+ header = <<~HEADER
19
+ # STOP! - DO NOT EDIT THIS FILE MANUALLY
20
+ # This file was automatically generated by "bin/codeownership validate".
21
+ #
22
+ # CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub
23
+ # teams. This is useful when developers create Pull Requests since the
24
+ # code/file owner is notified. Reference GitHub docs for more details:
25
+ # https://help.github.com/en/articles/about-code-owners
26
+ HEADER
27
+
28
+ contents = [
29
+ header,
30
+ *codeowners_file_lines,
31
+ nil, # For end-of-file newline
32
+ ].join("\n")
33
+
34
+ codeowners_up_to_date = codeowners_filepath.exist? && codeowners_filepath.read == contents
35
+
36
+ errors = T.let([], T::Array[String])
37
+
38
+ if !codeowners_up_to_date
39
+ if autocorrect
40
+ codeowners_filepath.write(contents)
41
+ if stage_changes
42
+ `git add #{codeowners_filepath}`
43
+ end
44
+ else
45
+ errors << "CODEOWNERS out of date. Ensure pre-commit hook is set up correctly and used. You can also run bin/codeownership validate to update the CODEOWNERS file\n"
46
+ end
47
+ end
48
+
49
+ errors
50
+ end
51
+
52
+ private
53
+
54
+ # Generate the contents of a CODEOWNERS file that GitHub can use to
55
+ # automatically assign reviewers
56
+ # https://help.github.com/articles/about-codeowners/
57
+ sig { returns(T::Array[String]) }
58
+ def codeowners_file_lines
59
+ github_team_map = Teams.all.each_with_object({}) do |team, map|
60
+ team_github = TeamPlugins::Github.for(team).github
61
+ next if team_github.do_not_add_to_codeowners_file
62
+
63
+ map[team.name] = team_github.team
64
+ end
65
+
66
+ Private.mappers.flat_map do |mapper|
67
+ codeowners_lines = mapper.codeowners_lines_to_owners.filter_map do |line, team|
68
+ team_mapping = github_team_map[team&.name]
69
+ next unless team_mapping
70
+
71
+ "/#{line} #{team_mapping}"
72
+ end
73
+ next [] if codeowners_lines.empty?
74
+
75
+ [
76
+ '',
77
+ "# #{mapper.description}",
78
+ *codeowners_lines.sort,
79
+ ]
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+
3
+ module CodeOwnership
4
+ module Private
5
+ module Validations
6
+ module Interface
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ interface!
11
+
12
+ sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
13
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require 'code_ownership/private/configuration'
6
+ require 'code_ownership/private/team_plugins/ownership'
7
+ require 'code_ownership/private/team_plugins/github'
8
+ require 'code_ownership/private/parse_js_packages'
9
+ require 'code_ownership/private/validations/interface'
10
+ require 'code_ownership/private/validations/files_have_owners'
11
+ require 'code_ownership/private/validations/github_codeowners_up_to_date'
12
+ require 'code_ownership/private/validations/files_have_unique_owners'
13
+ require 'code_ownership/private/ownership_mappers/interface'
14
+ require 'code_ownership/private/ownership_mappers/file_annotations'
15
+ require 'code_ownership/private/ownership_mappers/team_globs'
16
+ require 'code_ownership/private/ownership_mappers/package_ownership'
17
+ require 'code_ownership/private/ownership_mappers/js_package_ownership'
18
+
19
+ module CodeOwnership
20
+ module Private
21
+ extend T::Sig
22
+
23
+ sig { returns(Private::Configuration) }
24
+ def self.configuration
25
+ @configuration ||= T.let(@configuration, T.nilable(Private::Configuration))
26
+ @configuration ||= Private::Configuration.fetch
27
+ end
28
+
29
+ sig { void }
30
+ def self.bust_caches!
31
+ @configuration = nil
32
+ @tracked_files = nil
33
+ @files_by_mapper = nil
34
+ end
35
+
36
+ sig { params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).void }
37
+ def self.validate!(files:, autocorrect: true, stage_changes: true)
38
+ validators = [
39
+ Validations::FilesHaveOwners.new,
40
+ Validations::FilesHaveUniqueOwners.new,
41
+ Validations::GithubCodeownersUpToDate.new,
42
+ ]
43
+
44
+ errors = validators.flat_map do |validator|
45
+ validator.validation_errors(
46
+ files: files,
47
+ autocorrect: autocorrect,
48
+ stage_changes: stage_changes
49
+ )
50
+ end
51
+
52
+ if errors.any?
53
+ errors << 'See https://github.com/bigrails/code_ownership/README.md for more details'
54
+ raise InvalidCodeOwnershipConfigurationError.new(errors.join("\n")) # rubocop:disable Style/RaiseArgs
55
+ end
56
+ end
57
+
58
+ sig { returns(T::Array[Private::OwnershipMappers::Interface]) }
59
+ def self.mappers
60
+ [
61
+ file_annotations_mapper,
62
+ Private::OwnershipMappers::TeamGlobs.new,
63
+ Private::OwnershipMappers::PackageOwnership.new,
64
+ Private::OwnershipMappers::JsPackageOwnership.new,
65
+ ]
66
+ end
67
+
68
+ sig { returns(Private::OwnershipMappers::FileAnnotations) }
69
+ def self.file_annotations_mapper
70
+ @file_annotations_mapper = T.let(@file_annotations_mapper, T.nilable(Private::OwnershipMappers::FileAnnotations))
71
+ @file_annotations_mapper ||= Private::OwnershipMappers::FileAnnotations.new
72
+ end
73
+
74
+ # Returns a string version of the relative path to a Rails constant,
75
+ # or nil if it can't find something
76
+ sig { params(klass: T.nilable(T.any(Class, Module))).returns(T.nilable(String)) }
77
+ def self.path_from_klass(klass)
78
+ if klass
79
+ path = Object.const_source_location(klass.to_s)&.first
80
+ (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil
81
+ else
82
+ nil
83
+ end
84
+ end
85
+
86
+ #
87
+ # The output of this function is string pathnames relative to the root.
88
+ #
89
+ sig { returns(T::Array[String]) }
90
+ def self.tracked_files
91
+ @tracked_files ||= T.let(@tracked_files, T.nilable(T::Array[String]))
92
+ @tracked_files ||= Dir.glob(configuration.owned_globs)
93
+ end
94
+
95
+ sig { params(team_name: String, location_of_reference: String).returns(Teams::Team) }
96
+ def self.find_team!(team_name, location_of_reference)
97
+ found_team = Teams.find(team_name)
98
+ if found_team.nil?
99
+ raise StandardError, "Could not find team with name: `#{team_name}` in #{location_of_reference}. Make sure the team is one of `#{Teams.all.map(&:name).sort}`"
100
+ else
101
+ found_team
102
+ end
103
+ end
104
+
105
+ sig { params(files: T::Array[String]).returns(T::Hash[String, T::Array[String]]) }
106
+ def self.files_by_mapper(files)
107
+ @files_by_mapper ||= T.let(@files_by_mapper, T.nilable(T::Hash[String, T::Array[String]]))
108
+ @files_by_mapper ||= begin
109
+ files_by_mapper = files.map { |file| [file, []] }.to_h
110
+
111
+ Private.mappers.each do |mapper|
112
+ mapper.map_files_to_owners(files).each do |file, _team|
113
+ files_by_mapper[file] ||= []
114
+ T.must(files_by_mapper[file]) << mapper.description
115
+ end
116
+ end
117
+
118
+ files_by_mapper
119
+ end
120
+ end
121
+ end
122
+
123
+ private_constant :Private
124
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require 'set'
6
+ require 'teams'
7
+ require 'sorbet-runtime'
8
+ require 'json'
9
+ require 'parse_packwerk'
10
+ require 'code_ownership/cli'
11
+ require 'code_ownership/private'
12
+
13
+ module CodeOwnership
14
+ extend self
15
+ extend T::Sig
16
+ extend T::Helpers
17
+
18
+ requires_ancestor { Kernel }
19
+
20
+ sig { params(file: String).returns(T.nilable(Teams::Team)) }
21
+ def for_file(file)
22
+ @for_file ||= T.let(@for_file, T.nilable(T::Hash[String, T.nilable(Teams::Team)]))
23
+ @for_file ||= {}
24
+
25
+ return nil if file.start_with?('./')
26
+ return @for_file[file] if @for_file.key?(file)
27
+
28
+ owner = T.let(nil, T.nilable(Teams::Team))
29
+
30
+ Private.mappers.each do |mapper|
31
+ owner = mapper.map_file_to_owner(file)
32
+ break if owner
33
+ end
34
+
35
+ @for_file[file] = owner
36
+ end
37
+
38
+ class InvalidCodeOwnershipConfigurationError < StandardError
39
+ end
40
+
41
+ sig { params(filename: String).void }
42
+ def self.remove_file_annotation!(filename)
43
+ Private.file_annotations_mapper.remove_file_annotation!(filename)
44
+ end
45
+
46
+ sig do
47
+ params(
48
+ files: T::Array[String],
49
+ autocorrect: T::Boolean,
50
+ stage_changes: T::Boolean
51
+ ).void
52
+ end
53
+ def validate!(
54
+ files: Private.tracked_files,
55
+ autocorrect: true,
56
+ stage_changes: true
57
+ )
58
+ tracked_file_subset = Private.tracked_files & files
59
+ Private.validate!(files: tracked_file_subset, autocorrect: autocorrect, stage_changes: stage_changes)
60
+ end
61
+
62
+ # Given a backtrace from either `Exception#backtrace` or `caller`, find the
63
+ # first line that corresponds to a file with assigned ownership
64
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::Teams::Team]).returns(T.nilable(::Teams::Team)) }
65
+ def for_backtrace(backtrace, excluded_teams: [])
66
+ return unless backtrace
67
+
68
+ # The pattern for a backtrace hasn't changed in forever and is considered
69
+ # stable: https://github.com/ruby/ruby/blob/trunk/vm_backtrace.c#L303-L317
70
+ #
71
+ # This pattern matches a line like the following:
72
+ #
73
+ # ./app/controllers/some_controller.rb:43:in `block (3 levels) in create'
74
+ #
75
+ backtrace_line = %r{\A(#{Pathname.pwd}/|\./)?
76
+ (?<file>.+) # Matches 'app/controllers/some_controller.rb'
77
+ :
78
+ (?<line>\d+) # Matches '43'
79
+ :in\s
80
+ `(?<function>.*)' # Matches "`block (3 levels) in create'"
81
+ \z}x
82
+
83
+ backtrace.each do |line|
84
+ match = line.match(backtrace_line)
85
+
86
+ if match
87
+ team = CodeOwnership.for_file(T.must(match[:file]))
88
+ if team && !excluded_teams.include?(team)
89
+ return team
90
+ end
91
+ end
92
+ end
93
+ nil
94
+ end
95
+
96
+ sig { params(klass: T.nilable(T.any(Class, Module))).returns(T.nilable(::Teams::Team)) }
97
+ def for_class(klass)
98
+ @memoized_values ||= T.let(@memoized_values, T.nilable(T::Hash[String, T.nilable(::Teams::Team)]))
99
+ @memoized_values ||= {}
100
+ # We use key because the memoized value could be `nil`
101
+ if !@memoized_values.key?(klass.to_s)
102
+ path = Private.path_from_klass(klass)
103
+ return nil if path.nil?
104
+
105
+ value_to_memoize = for_file(path)
106
+ @memoized_values[klass.to_s] = value_to_memoize
107
+ value_to_memoize
108
+ else
109
+ @memoized_values[klass.to_s]
110
+ end
111
+ end
112
+
113
+ sig { params(package: ParsePackwerk::Package).returns(T.nilable(::Teams::Team)) }
114
+ def for_package(package)
115
+ Private::OwnershipMappers::PackageOwnership.new.owner_for_package(package)
116
+ end
117
+
118
+ # Generally, you should not ever need to do this, because once your ruby process loads, cached content should not change.
119
+ # Namely, the set of files, packages, and directories which are tracked for ownership should not change.
120
+ # The primary reason this is helpful is for clients of CodeOwnership who want to test their code, and each test context
121
+ # has different ownership and tracked files.
122
+ sig { void }
123
+ def self.bust_caches!
124
+ @for_file = nil
125
+ @memoized_values = nil
126
+ Private.bust_caches!
127
+ Private.mappers.each(&:bust_caches!)
128
+ end
129
+ end
data/sorbet/config ADDED
@@ -0,0 +1,4 @@
1
+ --dir
2
+ .
3
+ --ignore=/spec
4
+ --enable-experimental-requires-ancestor
@@ -0,0 +1,120 @@
1
+ # typed: true
2
+
3
+ # DO NOT EDIT MANUALLY
4
+ # This is an autogenerated file for types exported from the `bigrails-teams` gem.
5
+ # Please instead update this file by running `bin/tapioca gem bigrails-teams`.
6
+
7
+ module Teams
8
+ class << self
9
+ sig { returns(T::Array[::Teams::Team]) }
10
+ def all; end
11
+
12
+ sig { void }
13
+ def bust_caches!; end
14
+
15
+ sig { params(name: ::String).returns(T.nilable(::Teams::Team)) }
16
+ def find(name); end
17
+
18
+ sig { params(dir: ::String).returns(T::Array[::Teams::Team]) }
19
+ def for_directory(dir); end
20
+
21
+ sig { params(string: ::String).returns(::String) }
22
+ def tag_value_for(string); end
23
+
24
+ sig { params(teams: T::Array[::Teams::Team]).returns(T::Array[::String]) }
25
+ def validation_errors(teams); end
26
+ end
27
+ end
28
+
29
+ class Teams::IncorrectPublicApiUsageError < ::StandardError; end
30
+
31
+ class Teams::Plugin
32
+ abstract!
33
+
34
+ sig { params(team: ::Teams::Team).void }
35
+ def initialize(team); end
36
+
37
+ class << self
38
+ sig { returns(T::Array[T.class_of(Teams::Plugin)]) }
39
+ def all_plugins; end
40
+
41
+ sig { params(team: ::Teams::Team).returns(T.attached_class) }
42
+ def for(team); end
43
+
44
+ sig { params(base: T.untyped).void }
45
+ def inherited(base); end
46
+
47
+ sig { params(team: ::Teams::Team, key: ::String).returns(::String) }
48
+ def missing_key_error_message(team, key); end
49
+
50
+ sig { params(teams: T::Array[::Teams::Team]).returns(T::Array[::String]) }
51
+ def validation_errors(teams); end
52
+
53
+ private
54
+
55
+ sig { params(team: ::Teams::Team).returns(T.attached_class) }
56
+ def register_team(team); end
57
+
58
+ sig { returns(T::Hash[T.nilable(::String), T::Hash[::Class, ::Teams::Plugin]]) }
59
+ def registry; end
60
+ end
61
+ end
62
+
63
+ module Teams::Plugins; end
64
+
65
+ class Teams::Plugins::Identity < ::Teams::Plugin
66
+ sig { returns(::Teams::Plugins::Identity::IdentityStruct) }
67
+ def identity; end
68
+
69
+ class << self
70
+ sig { override.params(teams: T::Array[::Teams::Team]).returns(T::Array[::String]) }
71
+ def validation_errors(teams); end
72
+ end
73
+ end
74
+
75
+ class Teams::Plugins::Identity::IdentityStruct < ::Struct
76
+ def name; end
77
+ def name=(_); end
78
+
79
+ class << self
80
+ def [](*_arg0); end
81
+ def inspect; end
82
+ def members; end
83
+ def new(*_arg0); end
84
+ end
85
+ end
86
+
87
+ class Teams::Team
88
+ sig { params(config_yml: T.nilable(::String), raw_hash: T::Hash[T.untyped, T.untyped]).void }
89
+ def initialize(config_yml:, raw_hash:); end
90
+
91
+ sig { params(other: ::Object).returns(T::Boolean) }
92
+ def ==(other); end
93
+
94
+ sig { returns(T.nilable(::String)) }
95
+ def config_yml; end
96
+
97
+ def eql?(*args, &blk); end
98
+
99
+ sig { returns(::Integer) }
100
+ def hash; end
101
+
102
+ sig { returns(::String) }
103
+ def name; end
104
+
105
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
106
+ def raw_hash; end
107
+
108
+ sig { returns(::String) }
109
+ def to_tag; end
110
+
111
+ class << self
112
+ sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(::Teams::Team) }
113
+ def from_hash(raw_hash); end
114
+
115
+ sig { params(config_yml: ::String).returns(::Teams::Team) }
116
+ def from_yml(config_yml); end
117
+ end
118
+ end
119
+
120
+ Teams::UNKNOWN_TEAM_STRING = T.let(T.unsafe(nil), String)