code_ownership 1.23.0

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