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 +7 -0
- data/README.md +125 -0
- data/cleo_codeowners.gemspec +30 -0
- data/exe/cleo-codeowners +6 -0
- data/lib/cleo_codeowners/railtie.rb +11 -0
- data/lib/cleo_codeowners/version.rb +5 -0
- data/lib/cleo_codeowners.rb +5 -0
- data/lib/codeowners/cli.rb +230 -0
- data/lib/codeowners/codeowners_file.rb +36 -0
- data/lib/codeowners/configuration.rb +47 -0
- data/lib/codeowners/contributor_finder.rb +174 -0
- data/lib/codeowners/definitions_file.rb +90 -0
- data/lib/codeowners/generator.rb +172 -0
- data/lib/codeowners/glob.rb +45 -0
- data/lib/codeowners/owner.rb +40 -0
- data/lib/codeowners/owner_finder.rb +35 -0
- data/lib/codeowners/ownership.rb +40 -0
- data/lib/codeowners.rb +23 -0
- data/lib/tasks/codeowners.rake +10 -0
- metadata +103 -0
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
|
data/exe/cleo-codeowners
ADDED
|
@@ -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
|
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: []
|