codeowners-checker 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.projections.json +4 -0
- data/.rspec +3 -0
- data/.rubocop.yml +28 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +78 -0
- data/Guardfile +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +171 -0
- data/Rakefile +10 -0
- data/bin/codeowners-checker +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/codeowners-checker.gemspec +34 -0
- data/demos/missing_reference.svg +1 -0
- data/demos/useless_pattern.svg +1 -0
- data/lib/codeowners/checker.rb +123 -0
- data/lib/codeowners/checker/array.rb +15 -0
- data/lib/codeowners/checker/code_owners.rb +54 -0
- data/lib/codeowners/checker/file_as_array.rb +29 -0
- data/lib/codeowners/checker/group.rb +148 -0
- data/lib/codeowners/checker/group/comment.rb +32 -0
- data/lib/codeowners/checker/group/empty.rb +16 -0
- data/lib/codeowners/checker/group/group_begin_comment.rb +17 -0
- data/lib/codeowners/checker/group/group_end_comment.rb +17 -0
- data/lib/codeowners/checker/group/line.rb +83 -0
- data/lib/codeowners/checker/group/pattern.rb +46 -0
- data/lib/codeowners/checker/group/unrecognized_line.rb +13 -0
- data/lib/codeowners/checker/line_grouper.rb +106 -0
- data/lib/codeowners/checker/version.rb +7 -0
- data/lib/codeowners/cli/base.rb +34 -0
- data/lib/codeowners/cli/check.rb +8 -0
- data/lib/codeowners/cli/config.rb +24 -0
- data/lib/codeowners/cli/filter.rb +63 -0
- data/lib/codeowners/cli/main.rb +183 -0
- data/lib/codeowners/config.rb +48 -0
- metadata +239 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'line'
|
4
|
+
|
5
|
+
module Codeowners
|
6
|
+
class Checker
|
7
|
+
class Group
|
8
|
+
# Define and manage comment line.
|
9
|
+
class Comment < Line
|
10
|
+
# Matches if the line is a comment.
|
11
|
+
# @return [Boolean] if the line start with `#`
|
12
|
+
def self.match?(line)
|
13
|
+
line.start_with?('#')
|
14
|
+
end
|
15
|
+
|
16
|
+
# Return the comment level if the comment works like a markdown
|
17
|
+
# headers.
|
18
|
+
# @return [Integer] with the heading level.
|
19
|
+
#
|
20
|
+
# @example
|
21
|
+
# Comment.new('# First level').level # => 1
|
22
|
+
# Comment.new('## Second').level # => 2
|
23
|
+
def level
|
24
|
+
(@line[/^#+/] || '').size
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
require_relative 'group_begin_comment'
|
32
|
+
require_relative 'group_end_comment'
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'comment'
|
4
|
+
|
5
|
+
module Codeowners
|
6
|
+
class Checker
|
7
|
+
class Group
|
8
|
+
# Define line type GroupBeginComment which is used for defining the beggining
|
9
|
+
# of a group.
|
10
|
+
class GroupBeginComment < Comment
|
11
|
+
def self.match?(line)
|
12
|
+
line.lstrip.start_with?(/^#+ BEGIN/)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'comment'
|
4
|
+
|
5
|
+
module Codeowners
|
6
|
+
class Checker
|
7
|
+
class Group
|
8
|
+
# Define line type GroupEndComment which is used for defining the end
|
9
|
+
# of a group.
|
10
|
+
class GroupEndComment < Comment
|
11
|
+
def self.match?(line)
|
12
|
+
line.lstrip.start_with?(/^#+ END/)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Codeowners
|
4
|
+
class Checker
|
5
|
+
class Group
|
6
|
+
# It sorts lines from CODEOWNERS file to different line types and holds
|
7
|
+
# shared methods for all lines.
|
8
|
+
class Line
|
9
|
+
attr_accessor :parent
|
10
|
+
|
11
|
+
def self.build(line, transform_line_procs: nil)
|
12
|
+
subclasses.each do |klass|
|
13
|
+
return klass.new(line) if klass.match?(line)
|
14
|
+
end
|
15
|
+
UnrecognizedLine.new(line)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.subclasses
|
19
|
+
[Empty, GroupBeginComment, GroupEndComment, Comment, Pattern]
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(line)
|
23
|
+
@line = line
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_s
|
27
|
+
@line
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_content
|
31
|
+
to_s
|
32
|
+
end
|
33
|
+
|
34
|
+
def pattern?
|
35
|
+
is_a?(Pattern)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_tree(indentation)
|
39
|
+
indentation + to_s
|
40
|
+
end
|
41
|
+
|
42
|
+
def remove!
|
43
|
+
parent&.remove(self)
|
44
|
+
parent = nil
|
45
|
+
end
|
46
|
+
|
47
|
+
def ==(other)
|
48
|
+
return false unless other.is_a?(self.class)
|
49
|
+
|
50
|
+
other.to_s == to_s
|
51
|
+
end
|
52
|
+
|
53
|
+
def <=>(other)
|
54
|
+
to_s <=> other.to_s
|
55
|
+
end
|
56
|
+
|
57
|
+
# Pick all files from parent folder of pattern.
|
58
|
+
# This is used to build a list of suggestions case the pattern is not
|
59
|
+
# matching.
|
60
|
+
# If the pattern use "*/*" it will consider "."
|
61
|
+
# If the pattern uses Static files, it tries to reach the parent.
|
62
|
+
# If the pattern revers to the root folder, pick all files from the
|
63
|
+
# current pattern dir.
|
64
|
+
def suggest_files_for_pattern
|
65
|
+
parent_folders = pattern.split('/')[0..-2]
|
66
|
+
parent_folders << '*' if parent_folders[-1] != '*'
|
67
|
+
files = Dir[File.join(*parent_folders)] || []
|
68
|
+
files.map(&method(:normalize_path))
|
69
|
+
end
|
70
|
+
|
71
|
+
def normalize_path(file)
|
72
|
+
Pathname.new(file)
|
73
|
+
.relative_path_from(Pathname.new('.')).to_s
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
require_relative 'empty'
|
81
|
+
require_relative 'comment'
|
82
|
+
require_relative 'pattern'
|
83
|
+
require_relative 'unrecognized_line'
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'line'
|
4
|
+
|
5
|
+
module Codeowners
|
6
|
+
class Checker
|
7
|
+
class Group
|
8
|
+
# Defines and manages line type pattern.
|
9
|
+
class Pattern < Line
|
10
|
+
attr_accessor :pattern, :owners
|
11
|
+
|
12
|
+
def self.match?(line)
|
13
|
+
_pattern, *owners = line.split(/\s+/)
|
14
|
+
owners.any? && owners.all? { |owner| owner.include?('@') }
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(line)
|
18
|
+
super
|
19
|
+
parse(line)
|
20
|
+
end
|
21
|
+
|
22
|
+
def owner
|
23
|
+
owners.first
|
24
|
+
end
|
25
|
+
|
26
|
+
def parse(line)
|
27
|
+
@pattern, *@owners = line.split(/\s+/)
|
28
|
+
end
|
29
|
+
|
30
|
+
def match_file?(file)
|
31
|
+
regex.match(file)
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
[@pattern, @owners].join(' ')
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def regex
|
41
|
+
Regexp.new(pattern.gsub(%r{/\*\*}, '(/[^/]+)+').gsub(/\*/, '[^/]+'))
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Codeowners
|
4
|
+
class Checker
|
5
|
+
# Create groups and subgroups structure for the lines in the CODEOWNERS file.
|
6
|
+
class LineGrouper
|
7
|
+
def self.call(group, lines)
|
8
|
+
new(group, lines).call
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(group, lines)
|
12
|
+
@group_buffer = [group]
|
13
|
+
@lines = lines
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
lines.each_with_index do |line, index|
|
18
|
+
case line
|
19
|
+
when Codeowners::Checker::Group::Empty
|
20
|
+
ensure_groups_structure
|
21
|
+
when Codeowners::Checker::Group::GroupBeginComment
|
22
|
+
trim_groups(line.level)
|
23
|
+
create_groups_structure(line.level)
|
24
|
+
when Codeowners::Checker::Group::GroupEndComment
|
25
|
+
trim_subgroups(line.level)
|
26
|
+
create_groups_structure(line.level)
|
27
|
+
when Codeowners::Checker::Group::Comment
|
28
|
+
if previous_line_empty?(index)
|
29
|
+
trim_groups(line.level)
|
30
|
+
else
|
31
|
+
trim_subgroups(line.level)
|
32
|
+
end
|
33
|
+
create_groups_structure(line.level)
|
34
|
+
when Codeowners::Checker::Group::Pattern
|
35
|
+
if new_owner?(line, index)
|
36
|
+
trim_groups(current_level)
|
37
|
+
new_group
|
38
|
+
end
|
39
|
+
ensure_groups_structure
|
40
|
+
when Codeowners::Checker::Group::UnrecognizedLine
|
41
|
+
ensure_groups_structure
|
42
|
+
else
|
43
|
+
raise "Do not know how to handle line: #{line.inspect}"
|
44
|
+
end
|
45
|
+
current_group.add(line)
|
46
|
+
end
|
47
|
+
group_buffer.first
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :group_buffer, :lines
|
53
|
+
|
54
|
+
def previous_line_empty?(index)
|
55
|
+
index.positive? && lines[index - 1].is_a?(Codeowners::Checker::Group::Empty)
|
56
|
+
end
|
57
|
+
|
58
|
+
def new_owner?(line, index)
|
59
|
+
if previous_line_empty?(index)
|
60
|
+
offset = 2
|
61
|
+
while (index - offset).positive?
|
62
|
+
case lines[index - offset]
|
63
|
+
when Codeowners::Checker::Group::GroupEndComment
|
64
|
+
nil
|
65
|
+
when Codeowners::Checker::Group::Comment
|
66
|
+
return false
|
67
|
+
when Codeowners::Checker::Group::Pattern
|
68
|
+
return line.owner != lines[index - offset].owner
|
69
|
+
end
|
70
|
+
offset += 1
|
71
|
+
end
|
72
|
+
end
|
73
|
+
false
|
74
|
+
end
|
75
|
+
|
76
|
+
def current_group
|
77
|
+
group_buffer.last
|
78
|
+
end
|
79
|
+
|
80
|
+
def current_level
|
81
|
+
group_buffer.length - 1
|
82
|
+
end
|
83
|
+
|
84
|
+
def new_group
|
85
|
+
group = current_group.create_subgroup
|
86
|
+
group_buffer << group
|
87
|
+
end
|
88
|
+
|
89
|
+
def ensure_groups_structure
|
90
|
+
new_group if current_level.zero?
|
91
|
+
end
|
92
|
+
|
93
|
+
def create_groups_structure(level)
|
94
|
+
new_group while current_level < level
|
95
|
+
end
|
96
|
+
|
97
|
+
def trim_groups(level)
|
98
|
+
group_buffer.slice!(level..-1)
|
99
|
+
end
|
100
|
+
|
101
|
+
def trim_subgroups(level)
|
102
|
+
trim_groups(level + 1)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thor'
|
4
|
+
|
5
|
+
require_relative '../config'
|
6
|
+
|
7
|
+
module Codeowners
|
8
|
+
module Cli
|
9
|
+
# Base collects shared methods used by all CLI sub commands
|
10
|
+
# It loads and validate the default config file or output an explanation
|
11
|
+
# about how to configure it.
|
12
|
+
class Base < Thor
|
13
|
+
def initialize(args = [], options = {}, config = {})
|
14
|
+
super
|
15
|
+
@config ||= config[:config] || default_config
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :config
|
21
|
+
|
22
|
+
def default_config
|
23
|
+
Codeowners::Config.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def help_stderr
|
27
|
+
save_stdout = $stdout
|
28
|
+
$stdout = $stderr
|
29
|
+
help
|
30
|
+
$stdout = save_stdout
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module Codeowners
|
6
|
+
module Cli
|
7
|
+
# Provide options for configuring the default owner used for filtering changes.
|
8
|
+
class Config < Base
|
9
|
+
default_task :list
|
10
|
+
|
11
|
+
desc 'list', 'List the default owner configured in the config file'
|
12
|
+
def list
|
13
|
+
puts(config.to_h.map { |name, value| "#{name}: #{value.inspect}" })
|
14
|
+
help_stderr if config.default_owner.empty?
|
15
|
+
end
|
16
|
+
|
17
|
+
desc 'owner <name>', 'Configure a default owner name'
|
18
|
+
def owner(name)
|
19
|
+
config.default_owner = name
|
20
|
+
puts "Default owner configured to #{name}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Codeowners
|
4
|
+
module Cli
|
5
|
+
# List changed files. Provide an option to list all the changed files grouped by
|
6
|
+
# the owner of the file or filter them and show only the files owned by default owner.
|
7
|
+
class Filter < Base
|
8
|
+
option :from, default: 'origin/master'
|
9
|
+
option :to, default: 'HEAD'
|
10
|
+
option :verbose, default: false, type: :boolean, aliases: '-v'
|
11
|
+
desc 'by <owner>', <<~DESC
|
12
|
+
Lists changed files owned by owner.
|
13
|
+
If no owner is specified, default owner is taken from the config file.
|
14
|
+
DESC
|
15
|
+
# option :local, default: false, type: :boolean, aliases: '-l'
|
16
|
+
# option :branch, default: '', aliases: '-b'
|
17
|
+
def by(owner = config.default_owner)
|
18
|
+
return if owner.empty?
|
19
|
+
|
20
|
+
changes = checker.changes_with_ownership(owner)
|
21
|
+
if changes.key?(owner)
|
22
|
+
changes.values.each { |file| puts file }
|
23
|
+
else
|
24
|
+
puts "Owner #{owner} not defined in .github/CODEOWNERS"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
option :from, default: 'origin/master'
|
29
|
+
option :to, default: 'HEAD'
|
30
|
+
option :verbose, default: false, type: :boolean, aliases: '-v'
|
31
|
+
desc 'all', 'Lists all changed files grouped by owner'
|
32
|
+
def all
|
33
|
+
changes = checker.changes_with_ownership.select { |_owner, val| val && !val.empty? }
|
34
|
+
changes.keys.each do |owner|
|
35
|
+
puts(owner + ":\n " + changes[owner].join("\n ") + "\n\n")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def initialize(args = [], options = {}, config = {})
|
40
|
+
super
|
41
|
+
@repo_base_path = `git rev-parse --show-toplevel`
|
42
|
+
if !@repo_base_path || @repo_base_path.empty?
|
43
|
+
raise 'You must be positioned in a git repository to use this tool'
|
44
|
+
end
|
45
|
+
|
46
|
+
@repo_base_path.chomp!
|
47
|
+
Dir.chdir(@repo_base_path)
|
48
|
+
|
49
|
+
@checker ||= config[:checker] || default_checker
|
50
|
+
end
|
51
|
+
|
52
|
+
default_task :by
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
attr_reader :checker
|
57
|
+
|
58
|
+
def default_checker
|
59
|
+
Codeowners::Checker.new(@repo_base_path, options[:from], options[:to])
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|