codeowners-checker 1.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.
@@ -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