code_owners 1.0.8 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 0c170835202aa29d1d863ac544a039dfa2aaca9b
4
- data.tar.gz: 732f6a87815efa4092ea1404b55180d9bde78f39
2
+ SHA256:
3
+ metadata.gz: 42ad83e6c56f3bb34904d52dde4eb0f689fde59ea59868ecd4c428f3f13c8762
4
+ data.tar.gz: f8abb4f85d6fdf586432d047d9c24a521ea4411853a4a36e88de57556698d6a1
5
5
  SHA512:
6
- metadata.gz: b205789714617196c0e067504e8beae29dc585a6601a79279475a3a17cc36b249d61e896c6972d8d2fc2b4780e5df406a86c92226ef6013fd6fb262087733ae6
7
- data.tar.gz: 4e7766d5ee8b1adf676974e8dce8345ce93d2b4b9e868d4295578d35b5656897621c0dcf00e227377024946e2d7429c695e2bf86539fbd24a5992fdce2c9e4fd
6
+ metadata.gz: 80e88fb5714e128bcb401a8ecd7be79e631c3105c370d7ac275bc2ffd60ac92182b5d6090ad60782cba0cc6af89a60a0758466d4ebc703269ad83ea8d8cf5e4e
7
+ data.tar.gz: fa22fe034e7256b8637564a74803c044e85c675399978e8ccfc4d114b1b88d958501f5143c205d8cc159c40a1f823f269567cf6e22a84df896e5161f6a385113
data/.github/CODEOWNERS CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  # empty line followed by a comment?! madness
6
6
  lib/* @jcheatham
7
+ **/bar/** @jcheatham
7
8
  spec/* @jcheatham
8
9
  Rakefile @jcheatham
9
10
 
data/Gemfile.lock CHANGED
@@ -1,27 +1,29 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- code_owners (1.0.8)
4
+ code_owners (2.0.1)
5
+ pathspec
5
6
  rake
6
7
 
7
8
  GEM
8
9
  remote: http://rubygems.org/
9
10
  specs:
10
- diff-lcs (1.3)
11
+ diff-lcs (1.5.0)
12
+ pathspec (0.2.1)
11
13
  rake (13.0.6)
12
- rspec (3.8.0)
13
- rspec-core (~> 3.8.0)
14
- rspec-expectations (~> 3.8.0)
15
- rspec-mocks (~> 3.8.0)
16
- rspec-core (3.8.2)
17
- rspec-support (~> 3.8.0)
18
- rspec-expectations (3.8.4)
14
+ rspec (3.11.0)
15
+ rspec-core (~> 3.11.0)
16
+ rspec-expectations (~> 3.11.0)
17
+ rspec-mocks (~> 3.11.0)
18
+ rspec-core (3.11.0)
19
+ rspec-support (~> 3.11.0)
20
+ rspec-expectations (3.11.0)
19
21
  diff-lcs (>= 1.2.0, < 2.0)
20
- rspec-support (~> 3.8.0)
21
- rspec-mocks (3.8.1)
22
+ rspec-support (~> 3.11.0)
23
+ rspec-mocks (3.11.1)
22
24
  diff-lcs (>= 1.2.0, < 2.0)
23
- rspec-support (~> 3.8.0)
24
- rspec-support (3.8.2)
25
+ rspec-support (~> 3.11.0)
26
+ rspec-support (3.11.0)
25
27
 
26
28
  PLATFORMS
27
29
  ruby
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  Utility gem for .github/CODEOWNERS introspection
2
2
 
3
+ GitHub's [CODEOWNERS rules](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) are allegedly based on the gitignore format with a few small exceptions.
4
+
3
5
  Install
4
6
  =======
5
7
 
@@ -14,7 +16,18 @@ Requirements
14
16
  Usage
15
17
  =====
16
18
 
17
- your/repo/path$ code_owners
19
+ ```
20
+ your/repo/path$ code_owners --help
21
+ usage: code_owners [options]
22
+ -u, --unowned Display unowned files only
23
+ -e, --error-unowned Exit with error status if any files are unowned
24
+ -i, --ignore FILE A file of gitignore patterns to filter out of results, may be specified multiple times, only supported by -n option for now
25
+ -l, --log Log stuff
26
+ -n, --no-git [experimental] Use a git-free, pure ruby based implementation
27
+ -v, --version Display the version of the gem
28
+ ```
29
+
30
+ Several of those get transformed into option flags that get passed along to CodeOwners.ownerships, see [bin/codeowners](https://github.com/jcheatham/code_owners/blob/main/bin/code_owners)
18
31
 
19
32
  Output
20
33
  ======
@@ -32,9 +45,9 @@ Maybe put it in a cleanliness test, like:
32
45
 
33
46
  ```ruby
34
47
  it "does not introduce new unowned files" do
35
- unowned_files = CodeOwners.ownerships.select { |f| f[:owner] == CodeOwners::NO_OWNER }
36
- # this number should only decrease, never increase!
37
- assert_equal 12345, unowned_files.count, "Claim ownership of your new files in .github/CODEOWNERS to fix this test!"
48
+ unowned_files = CodeOwners.ownerships.select { |f| f[:owner] == CodeOwners::NO_OWNER }
49
+ # this number should only decrease, never increase!
50
+ assert_equal 12345, unowned_files.count, "Claim ownership of your new files in .github/CODEOWNERS to fix this test!"
38
51
  end
39
52
  ```
40
53
 
data/bin/code_owners CHANGED
@@ -1,21 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- unless system('git --version > /dev/null')
4
- STDERR.puts 'Git does not appear to be installed.'
5
- exit 2
6
- end
7
-
8
- unless system('git rev-parse --is-inside-work-tree > /dev/null')
9
- STDERR.puts 'The current working directory must be a Git repo.'
10
- exit 3
11
- end
12
-
13
3
  $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
14
4
  require 'code_owners'
15
5
  require 'code_owners/version'
16
6
  require 'optparse'
17
7
 
18
- options = {}
8
+ options = {ignores: []}
19
9
  OptionParser.new do |opts|
20
10
  opts.banner = "usage: code_owners [options]"
21
11
  opts.on('-u', '--unowned', TrueClass, 'Display unowned files only') do |u|
@@ -24,14 +14,35 @@ OptionParser.new do |opts|
24
14
  opts.on('-e', '--error-unowned', TrueClass, 'Exit with error status if any files are unowned') do |e|
25
15
  options[:error_unowned] = e
26
16
  end
17
+ opts.on('-i', '--ignore FILE', String, 'A file of gitignore patterns to filter out of results, may be specified multiple times, only supported by -n option for now') do |i|
18
+ options[:ignores] << i
19
+ end
20
+ opts.on('-l', '--log', TrueClass, 'Log stuff') do |l|
21
+ options[:log] = l
22
+ end
23
+ opts.on('-n', '--no-git', TrueClass, '[experimental] Use a git-free, pure ruby based implementation') do |n|
24
+ options[:no_git] = n
25
+ end
27
26
  opts.on('-v', '--version', TrueClass, 'Display the version of the gem') do |_|
28
27
  puts "Version: #{CodeOwners::VERSION}"
29
28
  exit 0
30
29
  end
31
30
  end.parse!
32
31
 
32
+ unless options[:no_git]
33
+ unless system('git --version > /dev/null')
34
+ STDERR.puts 'Git does not appear to be installed.'
35
+ exit 2
36
+ end
37
+
38
+ unless system('git rev-parse --is-inside-work-tree > /dev/null')
39
+ STDERR.puts 'The current working directory must be a Git repo.'
40
+ exit 3
41
+ end
42
+ end
43
+
33
44
  unowned_error = false
34
- CodeOwners.ownerships.each do |ownership_status|
45
+ CodeOwners.ownerships(options).each do |ownership_status|
35
46
  owner_info = ownership_status[:owner].dup
36
47
  if owner_info != CodeOwners::NO_OWNER
37
48
  next if options[:unowned]
data/code_owners.gemspec CHANGED
@@ -19,4 +19,5 @@ Gem::Specification.new name, CodeOwners::VERSION do |s|
19
19
 
20
20
  s.add_development_dependency "rspec"
21
21
  s.add_runtime_dependency "rake"
22
+ s.add_runtime_dependency "pathspec"
22
23
  end
@@ -1,3 +1,3 @@
1
1
  module CodeOwners
2
- VERSION = "1.0.8"
2
+ VERSION = "2.0.1"
3
3
  end
data/lib/code_owners.rb CHANGED
@@ -1,26 +1,37 @@
1
1
  require "code_owners/version"
2
2
  require "tempfile"
3
+ require "pathspec"
3
4
 
4
5
  module CodeOwners
6
+
5
7
  NO_OWNER = 'UNOWNED'
8
+ CODEOWNER_PATTERN = /(.*?)\s+((?:[^\s]*@[^\s]+\s*)+)/
9
+ POTENTIAL_LOCATIONS = ["CODEOWNERS", "docs/CODEOWNERS", ".github/CODEOWNERS"]
10
+
6
11
  class << self
7
12
 
8
- # github's CODEOWNERS rules (https://help.github.com/articles/about-codeowners/) are allegedly based on the gitignore format.
9
- # but you can't tell ls-files to ignore tracked files via an arbitrary pattern file
10
- # so we need to jump through some hacky git-fu hoops
11
- #
12
- # -c "core.excludesfiles=somefile" -> tells git to use this as our gitignore pattern source
13
- # check-ignore -> debug gitignore / exclude files
14
- # --no-index -> don't look in the index when checking, can be used to debug why a path became tracked
15
- # -v -> verbose, outputs details about the matching pattern (if any) for each given pathname
16
- # -n -> non-matching, shows given paths which don't match any pattern
17
-
18
- def log(message)
19
- puts message
13
+ # helper function to create the lookup for when we have a file and want to find its owner
14
+ def file_ownerships(opts = {})
15
+ Hash[ ownerships(opts).map { |o| [o[:file], o] } ]
16
+ end
17
+
18
+ # this maps the collection of ownership patterns and owners to actual files
19
+ def ownerships(opts = {})
20
+ log("Calculating ownerships for #{opts.inspect}", opts)
21
+ patowns = pattern_owners(codeowners_data(opts), opts)
22
+ if opts[:no_git]
23
+ files = files_to_own(opts)
24
+ ownerships_by_ruby(patowns, files, opts)
25
+ else
26
+ ownerships_by_gitignore(patowns, opts)
27
+ end
20
28
  end
21
29
 
22
- def ownerships
23
- patterns = pattern_owners
30
+
31
+ ####################
32
+ # gitignore approach
33
+
34
+ def ownerships_by_gitignore(patterns, opts = {})
24
35
  git_owner_info(patterns.map { |p| p[0] }).map do |line, pattern, file|
25
36
  if line.empty?
26
37
  { file: file, owner: NO_OWNER, line: nil, pattern: nil }
@@ -35,35 +46,6 @@ module CodeOwners
35
46
  end
36
47
  end
37
48
 
38
- def search_codeowners_file
39
- paths = ["CODEOWNERS", "docs/CODEOWNERS", ".github/CODEOWNERS"]
40
- for path in paths
41
- current_file_path = File.join(current_repo_path, path)
42
- return current_file_path if File.exist?(current_file_path)
43
- end
44
- abort("[ERROR] CODEOWNERS file does not exist.")
45
- end
46
-
47
- # read the github file and spit out a slightly formatted list of patterns and their owners
48
- # Empty/invalid/commented lines are still included in order to preserve line numbering
49
- def pattern_owners
50
- codeowner_path = search_codeowners_file
51
- patterns = []
52
- File.read(codeowner_path).split("\n").each_with_index { |line, i|
53
- path_owner = line.split(/\s+@/, 2)
54
- if line.match(/^\s*(?:#.*)?$/)
55
- patterns.push ['', ''] # Comment/empty line
56
- elsif path_owner.length != 2 || (path_owner[0].empty? && !path_owner[1].empty?)
57
- log "Parse error line #{(i+1).to_s}: \"#{line}\""
58
- patterns.push ['', ''] # Invalid line
59
- else
60
- path_owner[1] = '@'+path_owner[1]
61
- patterns.push path_owner
62
- end
63
- }
64
- return patterns
65
- end
66
-
67
49
  def git_owner_info(patterns)
68
50
  make_utf8(raw_git_owner_info(patterns)).lines.map do |info|
69
51
  _, _exfile, line, pattern, file = info.strip.match(/^(.*):(\d*):(.*)\t(.*)$/).to_a
@@ -71,18 +53,147 @@ module CodeOwners
71
53
  end
72
54
  end
73
55
 
74
- # expects an array of gitignore compliant patterns
75
- # generates a check-ignore formatted string for each file in the repo
56
+ # IN: an array of gitignore* check-ignore compliant patterns
57
+ # OUT: a check-ignore formatted string for each file in the repo
58
+ #
59
+ # * https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#syntax-exceptions
60
+ # sadly you can't tell ls-files to ignore tracked files via an arbitrary pattern file
61
+ # so we jump through some hacky git-fu hoops
62
+ #
63
+ # -c "core.quotepath=off" ls-files -z # prevent quoting the path and null-terminate each line to assist with matching stuff with spaces
64
+ # -c "core.excludesfiles=somefile" # tells git to use this as our gitignore pattern source
65
+ # check-ignore # debug gitignore / exclude files
66
+ # --no-index # don't look in the index when checking, can be used to debug why a path became tracked
67
+ # -v # verbose, outputs details about the matching pattern (if any) for each given pathname
68
+ # -n # non-matching, shows given paths which don't match any pattern
76
69
  def raw_git_owner_info(patterns)
77
70
  Tempfile.open('codeowner_patterns') do |file|
78
71
  file.write(patterns.join("\n"))
79
72
  file.rewind
80
- `cd #{current_repo_path} && git -c \"core.quotepath=off\" ls-files | xargs -- git -c \"core.quotepath=off\" -c \"core.excludesfile=#{file.path}\" check-ignore --no-index -v -n`
73
+ `cd #{current_repo_path} && git -c \"core.quotepath=off\" ls-files -z | xargs -0 -- git -c \"core.quotepath=off\" -c \"core.excludesfile=#{file.path}\" check-ignore --no-index -v -n`
74
+ end
75
+ end
76
+
77
+
78
+ ###############
79
+ # ruby approach
80
+
81
+ def ownerships_by_ruby(patowns, files, opts = {})
82
+ pattern_list = build_ruby_patterns(patowns, opts)
83
+ unowned = { owner: NO_OWNER, line: nil, pattern: nil }
84
+
85
+ files.map do |file|
86
+ last_match = nil
87
+ # have a flag to go through in reverse order as potential optimization?
88
+ # really depends on the data
89
+ pattern_list.each do |p|
90
+ last_match = p if p[:pattern_regex].match(file)
91
+ end
92
+ (last_match || unowned).dup.tap{|h| h[:file] = file }
93
+ end
94
+ end
95
+
96
+ def build_ruby_patterns(patowns, opts = {})
97
+ pattern_list = []
98
+ patowns.each_with_index do |(pattern, owner), i|
99
+ next if pattern == ""
100
+ pattern_list << {
101
+ owner: owner,
102
+ line: i+1,
103
+ pattern: pattern,
104
+ # gsub because spec approach needs a little help matching remainder of tree recursively
105
+ pattern_regex: PathSpec::GitIgnoreSpec.new(pattern.gsub(/\/\*$/, "/**"))
106
+ }
81
107
  end
108
+ pattern_list
109
+ end
110
+
111
+
112
+ ##############
113
+ # helper stuff
114
+
115
+ # read the github file and spit out a slightly formatted list of patterns and their owners
116
+ # Empty/invalid/commented lines are still included in order to preserve line numbering
117
+ def pattern_owners(codeowner_data, opts = {})
118
+ patterns = []
119
+ codeowner_data.split("\n").each_with_index do |line, i|
120
+ stripped_line = line.strip
121
+ if stripped_line == "" || stripped_line.start_with?("#")
122
+ patterns << ['', ''] # Comment / empty line
123
+
124
+ elsif stripped_line.start_with?("!")
125
+ # unsupported per github spec
126
+ log("Parse error line #{(i+1).to_s}: \"#{line}\"", opts)
127
+ patterns << ['', '']
128
+
129
+ elsif stripped_line.match(CODEOWNER_PATTERN)
130
+ patterns << [$1, $2]
131
+
132
+ else
133
+ log("Parse error line #{(i+1).to_s}: \"#{line}\"", opts)
134
+ patterns << ['', '']
135
+
136
+ end
137
+ end
138
+ patterns
139
+ end
140
+
141
+ def log(message, opts = {})
142
+ puts message if opts[:log]
82
143
  end
83
144
 
84
145
  private
85
146
 
147
+ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
148
+ # To use a CODEOWNERS file, create a new file called CODEOWNERS in the root, docs/, or .github/ directory of the repository, in the branch where you'd like to add the code owners.
149
+
150
+ # if we have access to git, use that to figure out our current repo path and look in there for codeowners
151
+ # if we don't, this function will attempt to find it while walking back up the directory tree
152
+ def codeowners_data(opts = {})
153
+ if opts[:codeowner_data]
154
+ return opts[:codeowner_data]
155
+ elsif opts[:codeowner_path]
156
+ return File.read(opts[:codeowner_path]) if File.exist?(opts[:codeowner_path])
157
+ elsif opts[:no_git]
158
+ path = Dir.pwd.split(File::SEPARATOR)
159
+ while !path.empty?
160
+ POTENTIAL_LOCATIONS.each do |pl|
161
+ current_file_path = File.join(path, pl)
162
+ return File.read(current_file_path) if File.exist?(current_file_path)
163
+ end
164
+ path.pop
165
+ end
166
+ else
167
+ path = current_repo_path
168
+ POTENTIAL_LOCATIONS.each do |pl|
169
+ current_file_path = File.join(path, pl)
170
+ return File.read(current_file_path) if File.exist?(current_file_path)
171
+ end
172
+ end
173
+ raise("[ERROR] CODEOWNERS file does not exist.")
174
+ end
175
+
176
+ def files_to_own(opts = {})
177
+ # glob all files
178
+ all_files_pattern = File.join("**","**")
179
+
180
+ # optionally prefix with list of directories to scope down potential evaluation space
181
+ if opts[:scoped_dirs]
182
+ all_files_pattern = File.join("{#{opts[:scoped_dirs].join(",")}}", all_files_pattern)
183
+ end
184
+
185
+ all_files = Dir.glob(all_files_pattern, File::FNM_DOTMATCH)
186
+ all_files.reject!{|f| f.start_with?(".git/") || File.directory?(f) }
187
+
188
+ # filter out ignores if we have them
189
+ opts[:ignores]&.each do |ignore|
190
+ ignores = PathSpec.new(File.readlines(ignore, chomp: true).map{|i| i.end_with?("/*") ? "#{i}*" : i })
191
+ all_files.reject! { |f| ignores.specs.any?{|p| p.match(f) } }
192
+ end
193
+
194
+ all_files
195
+ end
196
+
86
197
  def make_utf8(input)
87
198
  input.force_encoding(Encoding::UTF_8)
88
199
  return input if input.valid_encoding?