code_owners 1.0.8 → 2.0.1

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 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?