cleo_codeowners 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b451268ca7f9665ef4508901f64fb59e870948c24608555927cd4175e2cd5b7
4
+ data.tar.gz: f845e65d47b0e85f6c37e113b9d281a857ad07cd2506bc961a5797395abcbddf
5
+ SHA512:
6
+ metadata.gz: eff3685362b27dbb5ca99c070ee274046c4f85f887e0a8680f705e8631caec387d6d6fd9d6cb7b97a9aefdd1b1f48c935bd399a618c51200b9731dd62061e3d5
7
+ data.tar.gz: 37f6cf695c019c07957cc630e09ab9b0e729065c25b02747a685a540b10d443b757d5776fb914b212d5eef15339a814370c2828a65ab4fd982ddcd831ae35cba
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Cleo Codeowners
2
+
3
+ Manage your github CODEOWNERS files across multiple teams and features.
4
+
5
+ ## Summary
6
+
7
+ `cleo_codeowners` builds GitHub CODEOWNERS from small YAML files. Keep owners, features, and file globs in `.cleo/codeowners/**/*.yml`; generate `.github/CODEOWNERS` and `.github/CODEOWNERS-HUMAN`.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ gem install cleo_codeowners
13
+ ```
14
+
15
+ Or add it to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "cleo_codeowners"
19
+ ```
20
+
21
+ In Rails, the gem adds a rake task:
22
+
23
+ ```sh
24
+ bin/rails codeowners:generate
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Set the GitHub organization name before generating CODEOWNERS.
30
+
31
+ In a Rails app, add an initializer:
32
+
33
+ ```ruby
34
+ # config/initializers/codeowners.rb
35
+ Codeowners.configure do |config|
36
+ config.organization_name = "my_org"
37
+ end
38
+ ```
39
+
40
+ In a non-Rails app, configure Codeowners in the Rakefile while setting up the rake task:
41
+
42
+ ```ruby
43
+ # Rakefile
44
+ require "cleo_codeowners"
45
+
46
+ Codeowners.configure do |config|
47
+ config.organization_name = "my_org"
48
+ end
49
+
50
+ task :environment
51
+
52
+ load Gem.loaded_specs["cleo_codeowners"].full_gem_path + "/lib/tasks/codeowners.rake"
53
+ ```
54
+
55
+ ## Writing your codeowners files
56
+
57
+ Create YAML files under `.cleo/codeowners/`.
58
+
59
+ ```yaml
60
+ # .cleo/codeowners/features.yml
61
+ features:
62
+ session management:
63
+ session expiry:
64
+ billing:
65
+ ```
66
+
67
+ ```yaml
68
+ # .cleo/codeowners/owners.yml
69
+ owners:
70
+ session management: identity-platform
71
+ billing: payments
72
+ ```
73
+
74
+ Child features inherit their parent owner unless they define one.
75
+
76
+ ```yaml
77
+ # .cleo/codeowners/files/session_management.yml
78
+ files:
79
+ session management:
80
+ - /app/controllers/sessions/
81
+ - /app/models/session.rb
82
+ session expiry:
83
+ - /app/services/session_expiry/
84
+
85
+ # .cleo/codeowners/files/billing.yml
86
+ files:
87
+ billing:
88
+ - /app/controllers/billing/
89
+ - /app/models/invoice.rb
90
+ ```
91
+
92
+ Generate:
93
+
94
+ ```sh
95
+ bin/rails codeowners:generate
96
+ ```
97
+
98
+ Inspect:
99
+
100
+ ```sh
101
+ cleo-codeowners find_feature app/models/session.rb
102
+ cleo-codeowners find_owner app/models/session.rb --glob
103
+ cleo-codeowners find_unowned_files --pattern='app/**/*.rb' --exit-status-on-match=1
104
+ cleo-codeowners find_contributors "session management" --max-commits=100
105
+ ```
106
+
107
+ ## Development
108
+
109
+ Requires Ruby `>= 3.2.0`.
110
+
111
+ Install missing gems:
112
+
113
+ ```sh
114
+ gem install rake thor activesupport mocha minitest
115
+ ```
116
+
117
+ Run tests:
118
+
119
+ ```sh
120
+ rake test
121
+ ```
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/cleo_codeowners/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'cleo_codeowners'
7
+ spec.version = CleoCodeowners::VERSION
8
+ spec.authors = %w[@agentAngelope @bodacious @sldblog]
9
+
10
+ spec.summary = 'Cleo CODEOWNERS tooling'
11
+ spec.description = 'Tools for reading Cleo CODEOWNERS definitions and generating GitHub CODEOWNERS files.'
12
+ spec.homepage = 'https://github.com/meetcleo/meetcleo'
13
+ spec.license = 'MIT'
14
+ spec.required_ruby_version = '>= 3.2.0'
15
+
16
+ spec.metadata['rubygems_mfa_required'] = 'true'
17
+
18
+ spec.files =
19
+ Dir.glob("{#{File.basename(__FILE__)},README.md,lib/**/*,exe/*}", File::FNM_DOTMATCH).select do |path|
20
+ File.file?(path) && !File.symlink?(path)
21
+ end
22
+
23
+ spec.bindir = 'exe'
24
+ spec.executables = ['cleo-codeowners']
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.add_dependency 'activesupport'
28
+ spec.add_dependency 'rake'
29
+ spec.add_dependency 'thor'
30
+ end
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'codeowners/cli'
5
+
6
+ Codeowners::CLI.start(ARGV)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module CleoCodeowners
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path('../tasks/codeowners.rake', __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoCodeowners
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cleo_codeowners/version'
4
+ require_relative 'codeowners'
5
+ require_relative 'cleo_codeowners/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/string/inflections'
5
+ require 'English'
6
+ require 'pathname'
7
+ require 'thor'
8
+ require_relative '../codeowners'
9
+
10
+ module Codeowners
11
+ class CLI < Thor
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ desc 'find_files feature (glob)', 'Find all files under a particular feature, matching the GLOB file pattern'
17
+ def find_files(feature, glob = '*')
18
+ files = DefinitionsFile.new.feature_paths(feature:).select { |file| file.fnmatch?(glob) }
19
+ puts files.join("\n")
20
+ end
21
+
22
+ desc 'find_test_files feature', 'Find all test files under a particular feature'
23
+ def find_test_files(feature)
24
+ find_files(feature, '*_test.rb')
25
+ end
26
+
27
+ desc 'find_contributors FEATURE', 'Find top contributors for a feature based on git history'
28
+ method_option :max_commits, type: :numeric, default: 50, desc: 'Maximum number of commits to analyze'
29
+ def find_contributors(feature)
30
+ contributor_finder = ContributorFinder.new(max_commits: options[:max_commits])
31
+ contributors = contributor_finder.find_contributors(feature:)
32
+
33
+ if contributors.empty?
34
+ puts "No contributors found for feature '#{feature}'"
35
+ exit(0)
36
+ end
37
+
38
+ first_date = contributors.filter_map(&:first_commit_date).min
39
+ last_date = contributors.filter_map(&:last_commit_date).max
40
+ date_range = first_date && last_date ? "#{first_date} to #{last_date}" : 'N/A'
41
+ total_commits = contributors.sum(&:commits)
42
+
43
+ puts "Top contributors for feature '#{feature}'"
44
+ puts "Analyzed: last #{options[:max_commits]} commits (#{date_range})"
45
+ puts "Total commits: #{total_commits}"
46
+ puts
47
+
48
+ table_format = '%-30s %10s %10s %15s %8s'
49
+ puts format(table_format, 'GitHub Username', 'Additions', 'Deletions', 'Lines Changed', 'Commits')
50
+ puts '-' * 81
51
+ contributors.each do |contributor|
52
+ puts format(
53
+ table_format,
54
+ contributor.username,
55
+ contributor.additions,
56
+ contributor.deletions,
57
+ contributor.lines_changed,
58
+ contributor.commits
59
+ )
60
+ end
61
+ end
62
+
63
+ desc 'find_owner filepath', 'Find who owns a given file'
64
+ method_option :glob, type: :boolean, aliases: '-g', desc: 'Output the pattern matched'
65
+ def find_owner(filepath)
66
+ codeowners_file = CodeownersFile.new
67
+ owner_finder = OwnerFinder.new(codeowners_file:)
68
+ ownership = owner_finder.find_ownership_for_file(filepath:)
69
+ owners = ownership&.owners
70
+
71
+ unless owners&.any?
72
+ puts 'No owners found!'
73
+ exit(1)
74
+ end
75
+
76
+ output = owners.join(' ')
77
+ output << " #{ownership.glob}" if options[:glob]
78
+
79
+ puts output
80
+ end
81
+
82
+ desc 'find_owned_files OWNER', 'Find files matched to a given OWNER in the CODEOWNERS file'
83
+ method_option :pattern, type: :string, default: '**/*', desc: 'Pattern to match files against'
84
+ def find_owned_files(owner)
85
+ warn_against_global_glob_pattern if options[:pattern] == '**/*'
86
+
87
+ codeowners_file = CodeownersFile.new
88
+ owner_finder = OwnerFinder.new(codeowners_file:)
89
+ owned_files = Dir.glob(options[:pattern]).select do |filepath|
90
+ next unless Pathname.new(filepath).file?
91
+ next unless owner_finder.find_owners_for_file(filepath:).include?(owner)
92
+
93
+ filepath
94
+ end
95
+
96
+ owner_long_name = Owner.new(owner).long_name
97
+ if owned_files.any?
98
+ puts "#{owned_files.length} #{'file'.pluralize(owned_files.length)} found belonging to owner #{owner_long_name}"
99
+ puts owned_files
100
+ else
101
+ puts "No files found belonging to owner #{owner_long_name}"
102
+ end
103
+ end
104
+
105
+ desc 'find_unowned_files', 'Find files in the repo that are not marked with an owner in CODEOWNERS'
106
+ method_option :pattern, type: :string, default: '**/*', desc: 'Pattern to match files against'
107
+ method_option :exit_status_on_match, type: :numeric, default: 0, desc: 'What exit status should I return on match?'
108
+ method_option :output, type: :string, aliases: '-o', desc: 'Output file path (disables color codes)'
109
+ def find_unowned_files
110
+ warn_against_global_glob_pattern if options[:pattern] == '**/*'
111
+
112
+ codeowners_file = CodeownersFile.new
113
+ pattern = options[:pattern]
114
+
115
+ git_ls_files_pattern = pattern == '**/*' ? '.' : pattern
116
+ git_tracked_files = `git ls-files #{git_ls_files_pattern} 2>&1`
117
+ unless $CHILD_STATUS.success?
118
+ warn "Failed to run git ls-files: #{git_tracked_files}"
119
+ exit(1)
120
+ end
121
+
122
+ filepaths = git_tracked_files.split("\n").select do |filepath|
123
+ filepath_obj = Pathname.new(filepath)
124
+ next if filepath_obj.directory?
125
+
126
+ codeowners_file.globs.none? { |glob| glob.match?(filepath) }
127
+ end
128
+
129
+ output_stream = options[:output] ? File.open(options[:output], 'w') : $stdout
130
+ use_colors = options[:output].nil?
131
+
132
+ begin
133
+ if filepaths.empty?
134
+ output_with_color(output_stream, use_colors, "\e[32m", "No unowned files found in #{options[:pattern]}")
135
+ exit(0)
136
+ else
137
+ truncated_paths = truncate_to_unowned_directories(filepaths)
138
+ output_with_color(output_stream, use_colors, "\e[31m",
139
+ "The following #{'path'.pluralize(truncated_paths.length)} are not included in the CODEOWNERS file:")
140
+ truncated_paths.each do |filepath|
141
+ output_with_color(output_stream, use_colors, "\e[31m", filepath)
142
+ end
143
+ exit(options[:exit_status_on_match])
144
+ end
145
+ ensure
146
+ output_stream.close if options[:output]
147
+ end
148
+ end
149
+
150
+ desc 'find_feature filepath', 'Find which feature a given file belongs to'
151
+ def find_feature(filepath)
152
+ feature = DefinitionsFile.new.find_feature_for_file(path: filepath)
153
+ if feature
154
+ puts feature
155
+ else
156
+ puts "No feature found for #{filepath}"
157
+ exit(1)
158
+ end
159
+ end
160
+
161
+ desc 'find_features filepath', 'Find all matching features a given file belongs to'
162
+ method_option :show_path, type: :boolean, default: false, desc: 'Print the path (useful when finding many paths)'
163
+ def find_features(filepath)
164
+ feature_matches = DefinitionsFile.new.find_features_for_file(path: filepath)
165
+ if feature_matches.any?
166
+ feature_matches.each do |match|
167
+ print = []
168
+ print << filepath if options[:show_path]
169
+ print << match.feature
170
+ print << match.path_pattern
171
+ puts print.join(',')
172
+ end
173
+ else
174
+ puts "No feature found for #{filepath}"
175
+ exit(1)
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ def output_with_color(stream, use_colors, color_code, text)
182
+ if use_colors
183
+ stream.puts "#{color_code} #{text} \e[0m"
184
+ else
185
+ stream.puts text
186
+ end
187
+ end
188
+
189
+ def truncate_to_unowned_directories(filepaths)
190
+ return [] if filepaths.empty?
191
+
192
+ unowned_set = filepaths.to_set
193
+ result = []
194
+ processed_dirs = Set.new
195
+
196
+ directories = filepaths.map do |filepath|
197
+ File.dirname(filepath)
198
+ end.uniq.sort_by { |directory| directory.count('/') }
199
+
200
+ directories.each do |directory|
201
+ next if directory == '.'
202
+ next if processed_dirs.any? { |processed| directory.start_with?("#{processed}/") }
203
+
204
+ git_files_in_dir = `git ls-files -- '#{directory}/' 2>&1`.split("\n")
205
+ next unless $CHILD_STATUS.success?
206
+ next if git_files_in_dir.empty?
207
+
208
+ git_files_in_dir.select! { |file| File.file?(file) }
209
+
210
+ if git_files_in_dir.all? { |file| unowned_set.include?(file) }
211
+ result << "#{directory}/"
212
+ processed_dirs << directory
213
+ end
214
+ end
215
+
216
+ filepaths.each do |filepath|
217
+ result << filepath unless processed_dirs.any? { |directory| filepath.start_with?("#{directory}/") }
218
+ end
219
+
220
+ result.sort.uniq
221
+ end
222
+
223
+ def warn_against_global_glob_pattern
224
+ warn <<~WARNING
225
+ Scanning all files in this repository (--pattern=**/*) may take a while...
226
+ You may want to use a specific glob pattern e.g. (--pattern=app/models/**/*.rb)
227
+ WARNING
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,36 @@
1
+ # typed: false
2
+
3
+ # frozen_string_literal: true
4
+
5
+ module Codeowners
6
+ # Parses a Codeowners file
7
+ class CodeownersFile
8
+ require 'forwardable'
9
+ require_relative 'glob'
10
+ require_relative 'ownership'
11
+
12
+ extend Forwardable
13
+
14
+ attr_reader :source
15
+ private :source
16
+
17
+ def_delegators :source, :lines, :to_s
18
+
19
+ # @param [String] codeowners_source A String containing the readlines from the CODEOWNERS file
20
+ def initialize(codeowners_source = File.read('./.github/CODEOWNERS'))
21
+ @source = codeowners_source
22
+ end
23
+
24
+ def ownerships_reversed
25
+ @ownerships_reversed ||= ownerships.reverse
26
+ end
27
+
28
+ def ownerships
29
+ @ownerships ||= lines.map { |line| Ownership.new(*line.to_s.split) }
30
+ end
31
+
32
+ def globs
33
+ @globs ||= lines.map { |line| Glob.new(line.to_s.split.first) }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ # Stores configuration options for the Codeowners gem.
5
+ class Configuration
6
+ ##
7
+ # Organization/team name strings should begin with an @ symbol
8
+ AT_PREFIX = '@'
9
+ private_constant :AT_PREFIX
10
+
11
+ ##
12
+ # Raised when a required configuration value is missing
13
+ class MissingConfigurationError < Error; end
14
+
15
+ def self.instance
16
+ @instance ||= new
17
+ end
18
+
19
+ def self.reset_singleton_instance!
20
+ @instance = nil
21
+ end
22
+
23
+ ##
24
+ # @param [String] organization_name the name of the Github organization
25
+ def initialize(organization_name: nil)
26
+ self.organization_name = organization_name
27
+ end
28
+
29
+ ##
30
+ # The organization name for teams
31
+ # @return [String] the name of the organization
32
+ def organization_name
33
+ raise MissingConfigurationError, 'Organization name is required' if @organization_name.nil?
34
+
35
+ @organization_name.dup
36
+ end
37
+
38
+ ##
39
+ # Set the default organization name for teams
40
+ # @param [String] value the name of the organization
41
+ def organization_name=(value)
42
+ value = value.to_s.dup
43
+ value.prepend(AT_PREFIX) unless value.start_with?(AT_PREFIX)
44
+ @organization_name = value
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,174 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'open3'
6
+ require_relative 'definitions_file'
7
+
8
+ module Codeowners
9
+ class ContributorFinder
10
+ DEFAULT_MAX_COMMITS = 50
11
+
12
+ ContributorStats = Data.define(:additions, :deletions, :commits, :dates)
13
+ Contributor = Data.define(:email, :username, :lines_changed, :additions, :deletions, :commits, :first_commit_date,
14
+ :last_commit_date)
15
+
16
+ def initialize(definitions_file: DefinitionsFile.new, max_commits: DEFAULT_MAX_COMMITS)
17
+ @definitions_file = definitions_file
18
+ @max_commits = max_commits
19
+ @email_to_username_cache = {}
20
+ end
21
+
22
+ def find_contributors(feature:)
23
+ file_paths = collect_feature_files(feature)
24
+
25
+ return [] if file_paths.empty?
26
+
27
+ contributors = analyse_git_history(file_paths)
28
+ contributors.sort_by(&:lines_changed).reverse
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :definitions_file, :max_commits, :email_to_username_cache
34
+
35
+ def collect_feature_files(feature)
36
+ files = definitions_file.feature_paths(feature:)
37
+
38
+ subfeatures = find_all_subfeatures(feature)
39
+ subfeatures.each do |subfeature|
40
+ files.concat(definitions_file.feature_paths(feature: subfeature))
41
+ end
42
+
43
+ files.uniq.map(&:to_s)
44
+ end
45
+
46
+ def find_all_subfeatures(parent_feature, collected = [])
47
+ features_config = definitions_file.features_config
48
+ return collected unless features_config[parent_feature].is_a?(Hash)
49
+
50
+ features_config[parent_feature].each_key do |subfeature|
51
+ collected << subfeature
52
+ find_all_subfeatures(subfeature, collected)
53
+ end
54
+
55
+ collected
56
+ end
57
+
58
+ def analyse_git_history(file_paths)
59
+ return [] if file_paths.empty?
60
+
61
+ max_commits_str = max_commits.to_i
62
+ # Format: commit_hash|author_email|date
63
+ # Followed by numstat lines: additions\tdeletions\tfilename
64
+ git_args = ['git', 'log', '--numstat', "--max-count=#{max_commits_str}", '--format=%H|%ae|%ad', '--date=short',
65
+ '--']
66
+ git_args.concat(file_paths)
67
+
68
+ output, status = Open3.capture2(*git_args, err: %i[child out])
69
+ unless status.success?
70
+ warn('Failed to run git log', output:)
71
+ return []
72
+ end
73
+
74
+ parse_git_log(output)
75
+ end
76
+
77
+ # Parses git log output in the format:
78
+ # commit_hash|email|date
79
+ # <blank line>
80
+ # additions\tdeletions\tfilename
81
+ # additions\tdeletions\tfilename
82
+ # <blank line>
83
+ # commit_hash|email|date
84
+ # ...
85
+ def parse_git_log(output)
86
+ contributors_by_email = Hash.new { ContributorStats.new(additions: 0, deletions: 0, commits: 0, dates: []) }
87
+
88
+ current_email = nil
89
+ output.each_line do |line|
90
+ line.strip!
91
+ next if line.empty?
92
+
93
+ if commit_line?(line)
94
+ current_email = parse_commit_line(line, contributors_by_email)
95
+ elsif numstat_line?(line) && current_email
96
+ parse_numstat_line(line, contributors_by_email, current_email)
97
+ end
98
+ end
99
+
100
+ build_contributor_records(contributors_by_email)
101
+ end
102
+
103
+ def commit_line?(line)
104
+ line.include?('|')
105
+ end
106
+
107
+ def parse_commit_line(line, contributors_by_email)
108
+ _commit_hash, email, date = line.split('|')
109
+ stats = contributors_by_email[email]
110
+ contributors_by_email[email] = stats.with(
111
+ commits: stats.commits + 1,
112
+ dates: stats.dates + [date]
113
+ )
114
+ email
115
+ end
116
+
117
+ def numstat_line?(line)
118
+ line.match?(/^\d+\s+\d+\s+/)
119
+ end
120
+
121
+ def parse_numstat_line(line, contributors_by_email, email)
122
+ additions, deletions = line.split(/\s+/)
123
+ # Binary files show '-' for additions/deletions, skip them
124
+ return if additions == '-' || deletions == '-'
125
+
126
+ stats = contributors_by_email[email]
127
+ contributors_by_email[email] = stats.with(
128
+ additions: stats.additions + additions.to_i,
129
+ deletions: stats.deletions + deletions.to_i
130
+ )
131
+ end
132
+
133
+ def build_contributor_records(contributors_by_email)
134
+ contributors_by_email.map do |email, stats|
135
+ Contributor.new(
136
+ email:,
137
+ username: resolve_email_to_username(email),
138
+ lines_changed: stats.additions + stats.deletions,
139
+ additions: stats.additions,
140
+ deletions: stats.deletions,
141
+ commits: stats.commits,
142
+ first_commit_date: stats.dates.min,
143
+ last_commit_date: stats.dates.max
144
+ )
145
+ end
146
+ end
147
+
148
+ def resolve_email_to_username(email)
149
+ return email_to_username_cache[email] if email_to_username_cache.key?(email)
150
+
151
+ username = fetch_github_username(email)
152
+ email_to_username_cache[email] = username
153
+ username
154
+ end
155
+
156
+ def fetch_github_username(email)
157
+ query_param = "author-email:#{email}+repo:meetcleo/meetcleo"
158
+ gh_args = [
159
+ 'gh', 'api',
160
+ "/search/commits?q=#{query_param}",
161
+ '--jq', '.items[0].author.login'
162
+ ]
163
+
164
+ result, status = Open3.capture2(*gh_args, err: File::NULL)
165
+ result = result.strip
166
+
167
+ if status.success? && !result.empty? && result != 'null'
168
+ result
169
+ else
170
+ email.split('@').first
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,90 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'active_support/core_ext/hash/deep_merge'
5
+ require 'pathname'
6
+ require 'yaml'
7
+
8
+ module Codeowners
9
+ # Parses Cleo Codeowners YAML files
10
+ class DefinitionsFile
11
+ # @param [String] definition_glob String containing the glob pattern for the Cleo codeowner YAML files
12
+ def initialize(definition_glob = '.cleo/codeowners/**/*.y*ml')
13
+ @definition = Dir[definition_glob].inject({}) do |yaml, filepath|
14
+ yaml.deep_merge(YAML.load_file(filepath))
15
+ end
16
+ end
17
+
18
+ attr_reader :definition
19
+
20
+ def features_config
21
+ definition['features'] || {}
22
+ end
23
+
24
+ def owners_config
25
+ definition['owners'] || {}
26
+ end
27
+
28
+ def file_config
29
+ definition['files'] || {}
30
+ end
31
+
32
+ # Finds files defined under a feature, expands all globs, and returns all of them
33
+ # @return [Array,<Pathname>]
34
+ def feature_paths(feature:, directory: nil)
35
+ relative_globs = Array(file_config[feature]).map { |path| "./#{path}" }
36
+ resolve_local_paths(relative_globs:, directory: directory || Pathname.pwd)
37
+ end
38
+
39
+ # Finds a feature the file belongs to
40
+ # @return [nil, String]
41
+ def find_feature_for_file(path:)
42
+ return nil unless path
43
+
44
+ target_path = File.join('.', path)
45
+ file_config.each do |feature, paths|
46
+ next unless paths
47
+
48
+ paths.each do |feature_path|
49
+ path_pattern = File.join('.', feature_path)
50
+ path_pattern = "#{path_pattern}/*" if path_pattern.end_with?('/')
51
+ path_pattern.gsub!('//', '/')
52
+
53
+ return feature if File.fnmatch(path_pattern, target_path)
54
+ end
55
+ end
56
+
57
+ nil
58
+ end
59
+
60
+ Match = Data.define(:feature, :path_pattern)
61
+
62
+ # Finds a feature the file belongs to
63
+ # @return Array
64
+ def find_features_for_file(path:)
65
+ return [] unless path
66
+
67
+ target_path = File.join('.', path)
68
+ file_config.filter_map do |feature, paths|
69
+ next unless paths
70
+
71
+ paths.filter_map do |feature_path|
72
+ path_pattern = File.join('.', feature_path)
73
+ path_pattern = "#{path_pattern}/*" if path_pattern.end_with?('/')
74
+ path_pattern.gsub!('//', '/')
75
+
76
+ Match.new(feature, path_pattern) if File.fnmatch(path_pattern, target_path)
77
+ end
78
+ end.flatten.uniq
79
+ end
80
+
81
+ private
82
+
83
+ def resolve_local_paths(directory:, relative_globs:)
84
+ directory
85
+ .glob(relative_globs)
86
+ .flat_map { |pathname| pathname.directory? ? pathname.glob('**/*') : pathname }
87
+ .map { |pathname| pathname.relative_path_from(directory) }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,172 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Codeowners
5
+ class Generator
6
+ DEFAULT_OUTPUT_PATH = '.github/CODEOWNERS'
7
+ DEFAULT_HUMAN_OUTPUT_PATH = '.github/CODEOWNERS-HUMAN'
8
+
9
+ def initialize(
10
+ definitions_file: DefinitionsFile.new,
11
+ output_path: DEFAULT_OUTPUT_PATH,
12
+ human_output_path: DEFAULT_HUMAN_OUTPUT_PATH
13
+ )
14
+ @definitions_file = definitions_file
15
+ @output_path = output_path
16
+ @human_output_path = human_output_path
17
+ @calculated_owners = []
18
+ @all_features = []
19
+ end
20
+
21
+ def call
22
+ File.open(output_path, 'w') do |output_file|
23
+ File.open(human_output_path, 'w') do |output_human_file|
24
+ @output_file = output_file
25
+ @output_human_file = output_human_file
26
+
27
+ output(blurb, depth: 0)
28
+ calculate_owners_and_output_in_human_format(features: top_level_features)
29
+ validate_owners_in_features
30
+ output_in_machine_format
31
+ end
32
+ end
33
+ ensure
34
+ @output_file = nil
35
+ @output_human_file = nil
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :all_features,
41
+ :calculated_owners,
42
+ :definitions_file,
43
+ :human_output_path,
44
+ :output_file,
45
+ :output_human_file,
46
+ :output_path
47
+
48
+ def owners
49
+ @owners ||= definitions_file.owners_config
50
+ end
51
+
52
+ def top_level_features
53
+ @top_level_features ||= definitions_file.features_config
54
+ end
55
+
56
+ def files
57
+ @files ||= definitions_file.file_config
58
+ end
59
+
60
+ def calculate_owners_and_output_in_human_format(features:, current_owner: nil, depth: 0)
61
+ return unless present?(features)
62
+
63
+ features.sort_by(&:first).each do |feature, children|
64
+ output(feature.upcase, depth:)
65
+ owner = owners.fetch(feature, current_owner)
66
+ feature_files = files[feature]
67
+
68
+ if blank?(owner)
69
+ raise "Please assign an owner for #{feature} in owners.yaml!"
70
+ elsif blank?(feature_files) && blank?(children)
71
+ raise "Please assign files or child features to #{feature}, or remove it!"
72
+ else
73
+ all_features << feature
74
+ end
75
+
76
+ if owner == 'unowned'
77
+ output('Currently unowned', depth:)
78
+ else
79
+ output("Currently owned by @meetcleo/#{owner}", depth:)
80
+ end
81
+
82
+ if present?(feature_files)
83
+ feature_files.sort.uniq.each do |file|
84
+ record_calculated_owner(file, Owner.new(owner).long_name)
85
+ output("#{file} #{Owner.new(owner).long_name}", depth:, comment: false)
86
+ end
87
+ end
88
+
89
+ calculate_owners_and_output_in_human_format(features: children, current_owner: owner, depth: depth + 1)
90
+ end
91
+ end
92
+
93
+ def output(string, depth:, comment: true)
94
+ string.split("\n").each do |line|
95
+ line = if comment
96
+ "#{' ' * (depth * 2)}# #{line}"
97
+ else
98
+ "#{' ' * ((depth + 1) * 2)}#{line}"
99
+ end
100
+ output_human_file << line.rstrip << "\n"
101
+ end
102
+ end
103
+
104
+ def record_calculated_owner(path, owner)
105
+ calculated_owners.push({ path:, owner: })
106
+ end
107
+
108
+ def output_in_machine_format
109
+ file_rules, folder_rules = calculated_owners.partition { |owner| owner[:path].include?('.') }
110
+ group_by_path_segments_and_output(folder_rules)
111
+ group_by_path_segments_and_output(file_rules)
112
+ end
113
+
114
+ def group_by_path_segments_and_output(rules)
115
+ grouped_by_path_segments_number = rules
116
+ .group_by { |owner| sanitised_path(owner[:path]).split('/').reject(&:empty?).size }
117
+ .each_value do |rule|
118
+ rule.sort_by! do |owner|
119
+ owner[:path]
120
+ end
121
+ end
122
+ .sort
123
+ .to_h
124
+
125
+ grouped_by_path_segments_number
126
+ .each_value
127
+ .map { |files_and_owners| files_and_owners.group_by { |owner| owner[:path] } }
128
+ .each do |files_and_owners|
129
+ files_and_owners.each do |file, owners_list|
130
+ rule_owners = owners_list.map { |owner| owner[:owner] }.uniq.sort
131
+ output_file << "#{sanitised_path(file)} #{rule_owners.join(' ')}\n"
132
+ end
133
+ end
134
+ end
135
+
136
+ def blurb
137
+ <<~BLURB
138
+ This CODEOWNERS file is grouped by feature, using Github teams (a squad may
139
+ have one or more Github teams).
140
+
141
+ Please use the codeowners:generate rake task and the .cleo/codeowners yaml
142
+ configuration files to update this file.
143
+ BLURB
144
+ end
145
+
146
+ def sanitised_path(path)
147
+ return path if path.start_with?('/')
148
+
149
+ "/#{path}"
150
+ end
151
+
152
+ def validate_owners_in_features
153
+ features_missing_from_features_yaml = files.keys - all_features
154
+ return if features_missing_from_features_yaml.empty?
155
+
156
+ raise "The following features are missing from features.yaml:\n#{
157
+ features_missing_from_features_yaml.join("\n")
158
+ }"
159
+ end
160
+
161
+ def blank?(value)
162
+ return true if value.nil? || value == false
163
+ return value.strip.empty? if value.is_a?(String)
164
+
165
+ value.respond_to?(:empty?) && value.empty?
166
+ end
167
+
168
+ def present?(value)
169
+ !blank?(value)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,45 @@
1
+ # typed: false
2
+
3
+ # frozen_string_literal: true
4
+
5
+ module Codeowners
6
+ require 'delegate'
7
+
8
+ class Glob
9
+ require 'forwardable'
10
+ extend Forwardable
11
+
12
+ # The glob pattern this glob will match
13
+ #
14
+ # @return [String]
15
+ # @see #match?
16
+ attr_reader :pattern
17
+
18
+ def_delegator :File, :fnmatch, :fnmatch
19
+ def_delegator :pattern, :to_s
20
+
21
+ def initialize(string)
22
+ @pattern = normalize_string(string)
23
+ end
24
+
25
+ # Does this glob match the given filepath?
26
+ # @param [String] filepath to match against
27
+ # @note
28
+ # Don't use `FNM_CASEFOLD`, GitHub uses a case sensitive file system.
29
+ # Don't use `FNM_EXTGLOB`, GitHub CODEOWNERS files don't support it.
30
+ # Don't use `FNM_PATHNAME`, we want to match `*` against '/'.
31
+ #
32
+ # @see https://docs.ruby-lang.org/en/3.3/File.html#method-c-fnmatch
33
+ # @return [Boolean]
34
+ def match?(filepath)
35
+ fnmatch(pattern, filepath, File::FNM_DOTMATCH)
36
+ end
37
+
38
+ private
39
+
40
+ def normalize_string(string)
41
+ string.to_s.delete_prefix('/') # remove leading slashes
42
+ .gsub(%r{/\Z}, '/**') # replace trailing slash with a recursive wildcard
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,40 @@
1
+ # typed: false
2
+
3
+ # frozen_string_literal: true
4
+
5
+ module Codeowners
6
+ class Owner
7
+ ##
8
+ # Name of team in CODEOWNERS file (e.g. @meetcleo/chat)
9
+ # @return [String]
10
+ attr_reader :name
11
+ alias long_name name
12
+
13
+ ##
14
+ # Name of team in config files (e.g. chat)
15
+ # @return [String]
16
+ attr_reader :short_name
17
+
18
+ # @param [String] name_string Name of team in CODEOWNERS file (e.g. `"@meetcleo/chat"`)
19
+ def initialize(name_string)
20
+ name_string = "#{organization_name}/#{name_string}" unless name_string.start_with?(organization_name)
21
+ @name = name_string
22
+ @short_name = name_string.split('/').last
23
+ end
24
+
25
+ # Prefix for team names in the CODEOWNERS file
26
+ # @return [String]
27
+ def organization_name = Configuration.instance.organization_name
28
+
29
+ # Does this string match the name of the owner?
30
+ # @return [Boolean]
31
+ def match?(compare_string)
32
+ compare_string = "#{organization_name}/#{compare_string}" unless compare_string.start_with?(organization_name)
33
+ name == compare_string.to_s.downcase
34
+ end
35
+
36
+ def to_s
37
+ name.gsub(organization_name, '')
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Codeowners
5
+ class OwnerFinder
6
+ attr_reader :codeowners_file
7
+ private :codeowners_file
8
+
9
+ def self.singleton(...)
10
+ @singleton ||= new(...)
11
+ end
12
+
13
+ def initialize(codeowners_file:)
14
+ @codeowners_file = codeowners_file
15
+ end
16
+
17
+ # Find the most likely owners of the file with the given filepath. Returns the owner name as an Array of Strings
18
+ # (e.g. ["card-1-be", "card-2-be"])
19
+ # @return [Array,<String>]
20
+ def find_owners_for_file(filepath:)
21
+ last_matching_ownership = find_ownership_for_file(filepath:)
22
+ return [] if last_matching_ownership.nil?
23
+
24
+ last_matching_ownership.owners.map(&:to_s)
25
+ end
26
+
27
+ # Find the most likely owners of the file with the given filepath. Returns
28
+ # @return [Ownership]
29
+ def find_ownership_for_file(filepath:)
30
+ codeowners_file.ownerships_reversed.find do |ownership|
31
+ ownership.glob_match?(filepath)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,40 @@
1
+ # typed: false
2
+
3
+ # frozen_string_literal: true
4
+
5
+ module Codeowners
6
+ # The Ownership class represents an codeowners rule from a GitHub CODEOWNERS file.
7
+ # It includes methods to filter files matching the path expression and to check codeowners.
8
+ class Ownership
9
+ require 'forwardable'
10
+ extend Forwardable
11
+
12
+ require_relative 'glob'
13
+ require_relative 'owner'
14
+
15
+ # @return [Ownership::Glob] The path expression from the CODEOWNERS file
16
+ attr_reader :glob
17
+
18
+ # @return [Array<Codeowners::Owner>] The owners (team name) from the CODEOWNERS file
19
+ attr_reader :owners
20
+
21
+ # Initializes a new Ownership instance
22
+ #
23
+ # @param [String] glob The file path pattern from the CODEOWNERS file
24
+ # @param [Array<String>] owners The owners (team names) associated with the path expression
25
+ def initialize(glob, *owners)
26
+ @glob = Glob.new(glob)
27
+ @owners = owners.map { |owner| Owner.new(owner) }
28
+ end
29
+
30
+ # Checks if a given owner name matches the owner of this path expression
31
+ #
32
+ # @param owner_name [String] the owner name to check
33
+ # @return [Boolean] true if the given owner name matches, false otherwise
34
+ def owner?(owner_name)
35
+ owners.any? { |owner| owner.match?(owner_name) }
36
+ end
37
+
38
+ def_delegator :glob, :match?, :glob_match?
39
+ end
40
+ end
data/lib/codeowners.rb ADDED
@@ -0,0 +1,23 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Codeowners
5
+ # Base error for all Codeowners errors
6
+ class Error < StandardError; end
7
+
8
+ require_relative 'codeowners/configuration'
9
+ require_relative 'codeowners/owner'
10
+ require_relative 'codeowners/glob'
11
+ require_relative 'codeowners/ownership'
12
+ require_relative 'codeowners/codeowners_file'
13
+ require_relative 'codeowners/owner_finder'
14
+ require_relative 'codeowners/definitions_file'
15
+ require_relative 'codeowners/contributor_finder'
16
+ require_relative 'codeowners/generator'
17
+
18
+ def self.configure(&block)
19
+ yield(Configuration.instance) if block
20
+
21
+ Configuration.instance
22
+ end
23
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'codeowners'
4
+
5
+ namespace :codeowners do
6
+ desc 'Generates CODEOWNERS file from .cleo/codeowners'
7
+ task generate: [:environment] do
8
+ Codeowners::Generator.new.call
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cleo_codeowners
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - "@agentAngelope"
8
+ - "@bodacious"
9
+ - "@sldblog"
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 1980-01-02 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activesupport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rake
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: thor
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ description: Tools for reading Cleo CODEOWNERS definitions and generating GitHub CODEOWNERS
57
+ files.
58
+ executables:
59
+ - cleo-codeowners
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - cleo_codeowners.gemspec
65
+ - exe/cleo-codeowners
66
+ - lib/cleo_codeowners.rb
67
+ - lib/cleo_codeowners/railtie.rb
68
+ - lib/cleo_codeowners/version.rb
69
+ - lib/codeowners.rb
70
+ - lib/codeowners/cli.rb
71
+ - lib/codeowners/codeowners_file.rb
72
+ - lib/codeowners/configuration.rb
73
+ - lib/codeowners/contributor_finder.rb
74
+ - lib/codeowners/definitions_file.rb
75
+ - lib/codeowners/generator.rb
76
+ - lib/codeowners/glob.rb
77
+ - lib/codeowners/owner.rb
78
+ - lib/codeowners/owner_finder.rb
79
+ - lib/codeowners/ownership.rb
80
+ - lib/tasks/codeowners.rake
81
+ homepage: https://github.com/meetcleo/meetcleo
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ rubygems_mfa_required: 'true'
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 3.2.0
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 4.0.13
101
+ specification_version: 4
102
+ summary: Cleo CODEOWNERS tooling
103
+ test_files: []