codeowners-checker 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'line'
4
+
5
+ module Codeowners
6
+ class Checker
7
+ class Group
8
+ # Define line type empty line.
9
+ class Empty < Line
10
+ def self.match?(line)
11
+ line.empty?
12
+ end
13
+ end
14
+ end
15
+ end
16
+ 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 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'line'
4
+
5
+ module Codeowners
6
+ class Checker
7
+ class Group
8
+ # Hold lines which are not defined in other line classes.
9
+ class UnrecognizedLine < Line
10
+ end
11
+ end
12
+ end
13
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ class Checker
5
+ VERSION = '1.0.0'
6
+ end
7
+ 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ module Cli
5
+ class Check < Base
6
+ end
7
+ end
8
+ 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