code_owners 1.0.7 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd7998397d45dc60a1fb77fad414a119a9df181a61f5aa7b135ee674ebce9008
4
- data.tar.gz: 3c3e4a62f8fb6a7650c90c7ac1975faf93edb91503092fa4f9a3092687e34587
3
+ metadata.gz: baea109754326800382bc8665df1e20634680187a1209f612bcc14f1447a962e
4
+ data.tar.gz: 25975f589029d740031c6c14832341a605fa885386be3f88bcb992cb427b1bf0
5
5
  SHA512:
6
- metadata.gz: c635ae7dbc19ee970e7d8701545839437c20b8121af692d43c1b835693d53163f70fe5291984d28cbe4e41aecb9ce59dd43d75d33f3994b0e810e05dc87d26c9
7
- data.tar.gz: fa2c93f8181119b3dc6a5d08ebaba882c9875c92835917de40a6e6650ec6e108aaedfb028d1843d695da537eaaf2a7f877828c3d0383003730ec512ca140a020
6
+ metadata.gz: be4b52ceca387c455f3b597f47c3f61aed0d3c381eb4d52e6f2d9c25311d0505704ebb817af0ab73892521bdb1f804fc3a560bfb49c54e1f62b03bf20fe6b796
7
+ data.tar.gz: b31b2929d9023a3609bbd8286b4579ff87c8af5f4a6fc9025ccce9f8bdbddb65c09facf92c7a6873dd20423e925e083de41ba7de8800210ea2cf2b67b749082a
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,14 +1,16 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- code_owners (1.0.7)
4
+ code_owners (2.0.0)
5
+ pathspec
5
6
  rake
6
7
 
7
8
  GEM
8
9
  remote: http://rubygems.org/
9
10
  specs:
10
11
  diff-lcs (1.3)
11
- rake (12.3.3)
12
+ pathspec (0.2.1)
13
+ rake (13.0.6)
12
14
  rspec (3.8.0)
13
15
  rspec-core (~> 3.8.0)
14
16
  rspec-expectations (~> 3.8.0)
@@ -31,4 +33,4 @@ DEPENDENCIES
31
33
  rspec
32
34
 
33
35
  BUNDLED WITH
34
- 1.17.2
36
+ 1.17.3
data/README.md CHANGED
@@ -1,14 +1,33 @@
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
 
6
8
  gem install code_owners
7
9
 
10
+ Requirements
11
+ ============
12
+
13
+ * Ruby
14
+ * Git
15
+
8
16
  Usage
9
17
  =====
10
18
 
11
- 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)
12
31
 
13
32
  Output
14
33
  ======
@@ -24,12 +43,12 @@ Development
24
43
 
25
44
  Maybe put it in a cleanliness test, like:
26
45
 
27
- ```
28
- it "does not introduce new unowned files" do
29
- unowned_files = CodeOwners.ownerships.select { |f| f[:owner] == "UNOWNED" }
30
- # this number should only decrease, never increase!
31
- assert_equal 12345, unowned_files.count, "Claim ownership of your new files in .github/CODEOWNERS to fix this test!"
32
- end
46
+ ```ruby
47
+ it "does not introduce new unowned files" do
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!"
51
+ end
33
52
  ```
34
53
 
35
54
  Author
data/bin/code_owners CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
4
4
  require 'code_owners'
5
+ require 'code_owners/version'
5
6
  require 'optparse'
6
7
 
7
- options = {}
8
+ options = {ignores: []}
8
9
  OptionParser.new do |opts|
9
10
  opts.banner = "usage: code_owners [options]"
10
11
  opts.on('-u', '--unowned', TrueClass, 'Display unowned files only') do |u|
@@ -13,10 +14,35 @@ OptionParser.new do |opts|
13
14
  opts.on('-e', '--error-unowned', TrueClass, 'Exit with error status if any files are unowned') do |e|
14
15
  options[:error_unowned] = e
15
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
26
+ opts.on('-v', '--version', TrueClass, 'Display the version of the gem') do |_|
27
+ puts "Version: #{CodeOwners::VERSION}"
28
+ exit 0
29
+ end
16
30
  end.parse!
17
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
+
18
44
  unowned_error = false
19
- CodeOwners.ownerships.each do |ownership_status|
45
+ CodeOwners.ownerships(options).each do |ownership_status|
20
46
  owner_info = ownership_status[:owner].dup
21
47
  if owner_info != CodeOwners::NO_OWNER
22
48
  next if options[:unowned]
data/code_owners.gemspec CHANGED
@@ -8,10 +8,10 @@ Gem::Specification.new name, CodeOwners::VERSION do |s|
8
8
  s.description = "utility gem for .github/CODEOWNERS introspection"
9
9
  s.authors = "Jonathan Cheatham"
10
10
  s.email = "coaxis@gmail.com"
11
- s.homepage = "http://github.com/jcheatham/#{s.name}"
11
+ s.homepage = "https://github.com/jcheatham/#{s.name}"
12
12
  s.licenses = "MIT"
13
13
 
14
- s.files = `git ls-files`.split("\n")
14
+ s.files = `git -c "core.quotepath=off" ls-files`.split("\n")
15
15
  s.bindir = 'bin'
16
16
  s.test_files = `git ls-files -- test/*`.split("\n")
17
17
  s.require_paths = ["lib"]
@@ -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.7"
2
+ VERSION = "2.0.0"
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
+ patterns = pattern_owners(codeowners_data(opts), opts)
22
+ if opts[:no_git]
23
+ files = files_to_own(opts)
24
+ ownerships_by_ruby(patterns, files, opts)
25
+ else
26
+ ownerships_by_gitignore(patterns, 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,136 @@ 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 ls-files | xargs -- git -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`
81
74
  end
82
75
  end
83
76
 
77
+
78
+ ###############
79
+ # ruby approach
80
+
81
+ def ownerships_by_ruby(patterns, files, opts = {})
82
+ ownerships = files.map { |f| { file: f, owner: NO_OWNER, line: nil, pattern: nil } }
83
+
84
+ patterns.each_with_index do |(pattern, owner), i|
85
+ next if pattern == ""
86
+ pattern = pattern.gsub(/\/\*$/, "/**")
87
+ spec_pattern = PathSpec::GitIgnoreSpec.new(pattern)
88
+ ownerships.each do |o|
89
+ next unless spec_pattern.match(o[:file])
90
+ o[:owner] = owner
91
+ o[:line] = i+1
92
+ o[:pattern] = pattern
93
+ end
94
+ end
95
+
96
+ ownerships
97
+ end
98
+
99
+
100
+
101
+ ##############
102
+ # helper stuff
103
+
104
+ # read the github file and spit out a slightly formatted list of patterns and their owners
105
+ # Empty/invalid/commented lines are still included in order to preserve line numbering
106
+ def pattern_owners(codeowner_data, opts = {})
107
+ patterns = []
108
+ codeowner_data.split("\n").each_with_index do |line, i|
109
+ stripped_line = line.strip
110
+ if stripped_line == "" || stripped_line.start_with?("#")
111
+ patterns << ['', ''] # Comment / empty line
112
+
113
+ elsif stripped_line.start_with?("!")
114
+ # unsupported per github spec
115
+ log("Parse error line #{(i+1).to_s}: \"#{line}\"", opts)
116
+ patterns << ['', '']
117
+
118
+ elsif stripped_line.match(CODEOWNER_PATTERN)
119
+ patterns << [$1, $2]
120
+
121
+ else
122
+ log("Parse error line #{(i+1).to_s}: \"#{line}\"", opts)
123
+ patterns << ['', '']
124
+
125
+ end
126
+ end
127
+ patterns
128
+ end
129
+
130
+ def log(message, opts = {})
131
+ puts message if opts[:log]
132
+ end
133
+
84
134
  private
85
135
 
136
+ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
137
+ # 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.
138
+
139
+ # if we have access to git, use that to figure out our current repo path and look in there for codeowners
140
+ # if we don't, this function will attempt to find it while walking back up the directory tree
141
+ def codeowners_data(opts = {})
142
+ if opts[:codeowner_data]
143
+ return opts[:codeowner_data]
144
+ elsif opts[:codeowner_path]
145
+ return File.read(opts[:codeowner_path]) if File.exist?(opts[:codeowner_path])
146
+ elsif opts[:no_git]
147
+ path = Dir.pwd.split(File::SEPARATOR)
148
+ while !path.empty?
149
+ POTENTIAL_LOCATIONS.each do |pl|
150
+ current_file_path = File.join(path, pl)
151
+ return File.read(current_file_path) if File.exist?(current_file_path)
152
+ end
153
+ path.pop
154
+ end
155
+ else
156
+ path = current_repo_path
157
+ POTENTIAL_LOCATIONS.each do |pl|
158
+ current_file_path = File.join(path, pl)
159
+ return File.read(current_file_path) if File.exist?(current_file_path)
160
+ end
161
+ end
162
+ raise("[ERROR] CODEOWNERS file does not exist.")
163
+ end
164
+
165
+ def files_to_own(opts = {})
166
+ # glob all files
167
+ all_files_pattern = File.join("**","**")
168
+
169
+ # optionally prefix with list of directories to scope down potential evaluation space
170
+ if opts[:scoped_dirs]
171
+ all_files_pattern = File.join("{#{opts[:scoped_dirs].join(",")}}", all_files_pattern)
172
+ end
173
+
174
+ all_files = Dir.glob(all_files_pattern, File::FNM_DOTMATCH)
175
+ all_files.reject!{|f| f.start_with?(".git/") || File.directory?(f) }
176
+
177
+ # filter out ignores if we have them
178
+ opts[:ignores]&.each do |ignore|
179
+ ignores = PathSpec.new(File.readlines(ignore, chomp: true).map{|i| i.end_with?("/*") ? "#{i}*" : i })
180
+ all_files.reject! { |f| ignores.specs.any?{|p| p.match(f) } }
181
+ end
182
+
183
+ all_files
184
+ end
185
+
86
186
  def make_utf8(input)
87
187
  input.force_encoding(Encoding::UTF_8)
88
188
  return input if input.valid_encoding?