From bb4290c8f68f4901d92a935ef688db5e39a315b2 Mon Sep 17 00:00:00 2001 From: Greg Howdeshell Date: Mon, 16 Oct 2023 13:55:59 -0500 Subject: [PATCH] Remove usage of codeowner-checker from project and pulled in required classes (#11) --- CHANGELOG.md | 5 +- codeowner_validator.gemspec | 4 +- lib/codeowner_validator.rb | 4 +- lib/codeowner_validator/code_owners.rb | 3 +- .../helpers/utility_helper.rb | 9 +- lib/codeowner_validator/version.rb | 2 +- lib/codeowners/checker/array.rb | 15 + lib/codeowners/checker/group.rb | 158 ++++++++ lib/codeowners/checker/group/comment.rb | 32 ++ lib/codeowners/checker/group/empty.rb | 16 + .../checker/group/group_begin_comment.rb | 17 + .../checker/group/group_end_comment.rb | 17 + lib/codeowners/checker/group/line.rb | 61 ++- lib/codeowners/checker/group/pattern.rb | 73 ++++ .../checker/group/unrecognized_line.rb | 13 + lib/codeowners/checker/line_grouper.rb | 109 ++++++ lib/codeowners/checker/owner.rb | 12 + .../codeowners/checker/group/comment_spec.rb | 34 ++ .../lib/codeowners/checker/group/line_spec.rb | 58 +++ .../codeowners/checker/group/pattern_spec.rb | 231 ++++++++++++ spec/lib/codeowners/checker/group_spec.rb | 350 ++++++++++++++++++ 21 files changed, 1211 insertions(+), 12 deletions(-) create mode 100644 lib/codeowners/checker/array.rb create mode 100644 lib/codeowners/checker/group.rb create mode 100644 lib/codeowners/checker/group/comment.rb create mode 100644 lib/codeowners/checker/group/empty.rb create mode 100644 lib/codeowners/checker/group/group_begin_comment.rb create mode 100644 lib/codeowners/checker/group/group_end_comment.rb create mode 100644 lib/codeowners/checker/group/pattern.rb create mode 100644 lib/codeowners/checker/group/unrecognized_line.rb create mode 100644 lib/codeowners/checker/line_grouper.rb create mode 100644 lib/codeowners/checker/owner.rb create mode 100644 spec/lib/codeowners/checker/group/comment_spec.rb create mode 100644 spec/lib/codeowners/checker/group/line_spec.rb create mode 100644 spec/lib/codeowners/checker/group/pattern_spec.rb create mode 100644 spec/lib/codeowners/checker/group_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c03baea..4ac8f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,4 +11,7 @@ - Update to support ruby 3.2 ([#9](https://github.com/cerner/codeowner_validator/pull/9)) # 0.3.1 -- Back version of ruby to be in RVM supported set ([#10](https://github.com/cerner/codeowner_validator/pull/10)) \ No newline at end of file +- Back version of ruby to be in RVM supported set ([#10](https://github.com/cerner/codeowner_validator/pull/10)) + +# 0.4.0 +- Remove usage of codeowner-checker from project and pulled in required classes ([#11](https://github.com/cerner/codeowner_validator/pull/11)) diff --git a/codeowner_validator.gemspec b/codeowner_validator.gemspec index 737a0db..518a2ca 100644 --- a/codeowner_validator.gemspec +++ b/codeowner_validator.gemspec @@ -33,12 +33,12 @@ Gem::Specification.new do |spec| # rubocop:enable Gemspec/RequiredRubyVersion spec.add_dependency 'rainbow', '>= 2.0', '< 4.0.0' - spec.add_dependency 'thor', '>= 0.19' + spec.add_dependency 'thor', '>= 1.0' spec.add_dependency 'tty-prompt', '~> 0.12' spec.add_dependency 'tty-spinner', '~> 0.4' spec.add_dependency 'tty-table', '~> 0.8' - spec.add_dependency 'codeowners-checker', '~> 1.1' spec.add_dependency 'git', '~> 1.0' + spec.add_dependency 'pathspec', '>= 0.2' end diff --git a/lib/codeowner_validator.rb b/lib/codeowner_validator.rb index 5ffe486..c8423d2 100644 --- a/lib/codeowner_validator.rb +++ b/lib/codeowner_validator.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true require 'thor' -require 'codeowners/checker' -# pull in monkeypatch for codeowners-checker -require_relative 'codeowners/checker/group/line' Dir.glob(File.join(File.dirname(__FILE__), 'codeowner_validator', '**/*.rb'), &method(:require)) +Dir.glob(File.join(File.dirname(__FILE__), 'codeowners', '**/*.rb'), &method(:require)) # Public: The code owner validator space is utilized for validations against # the code owner file for a given repository. diff --git a/lib/codeowner_validator/code_owners.rb b/lib/codeowner_validator/code_owners.rb index 689d0fd..d8080b0 100644 --- a/lib/codeowner_validator/code_owners.rb +++ b/lib/codeowner_validator/code_owners.rb @@ -3,8 +3,7 @@ require 'pathname' require_relative 'helpers/utility_helper' require 'codeowner_validator/lists/whitelist' -require 'codeowners/checker/group' -require_relative '../codeowners/checker/group/line' +require_relative '../codeowners/checker/group' # rubocop:disable Style/ImplicitRuntimeError module CodeownerValidator diff --git a/lib/codeowner_validator/helpers/utility_helper.rb b/lib/codeowner_validator/helpers/utility_helper.rb index fce5def..35de30d 100644 --- a/lib/codeowner_validator/helpers/utility_helper.rb +++ b/lib/codeowner_validator/helpers/utility_helper.rb @@ -13,7 +13,14 @@ module UtilityHelper def in_folder(folder) raise "The folder location '#{folder}' does not exists" unless File.directory?(folder) - with_clean_env do + if defined?(Bundler) + method = Bundler.respond_to?(:with_unbundled_env) ? :with_unbundled_env : :with_clean_env + Bundler.send(method) do + Dir.chdir folder do + yield + end + end + else Dir.chdir folder do yield end diff --git a/lib/codeowner_validator/version.rb b/lib/codeowner_validator/version.rb index 6251378..d89ea91 100644 --- a/lib/codeowner_validator/version.rb +++ b/lib/codeowner_validator/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module CodeownerValidator - VERSION = '0.3.1' + VERSION = '0.4.0' # version module module Version diff --git a/lib/codeowners/checker/array.rb b/lib/codeowners/checker/array.rb new file mode 100644 index 0000000..0230d1c --- /dev/null +++ b/lib/codeowners/checker/array.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Codeowners + class Checker + # Array.delete in contrary to Ruby documentation uses == instead of equal? for comparison. + # safe_delete removes an object from an array comparing objects by equal? method. + module Array + def safe_delete(object) + delete_at(index { |item| item.equal?(object) }) + end + end + end +end + +Array.prepend(Codeowners::Checker::Array) diff --git a/lib/codeowners/checker/group.rb b/lib/codeowners/checker/group.rb new file mode 100644 index 0000000..3d3f9cc --- /dev/null +++ b/lib/codeowners/checker/group.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require_relative 'line_grouper' +require_relative 'group/line' +require_relative 'array' + +module Codeowners + class Checker + # Manage the groups content and handle operations on the groups. + class Group + include Enumerable + + attr_accessor :parent + + def self.parse(lines) + new.parse(lines) + end + + def initialize + @list = [] + end + + def each(&block) + @list.each do |object| + if object.is_a?(Group) + object.each(&block) + else + yield(object) + end + end + end + + def parse(lines) + LineGrouper.call(self, lines) + end + + def to_content + @list.flat_map(&:to_content) + end + + def to_file + @list.flat_map(&:to_file) + end + + # Returns an array of strings representing the structure of the group. + # It indent internal subgroups for readability and debugging purposes. + def to_tree(indentation = '') + @list.each_with_index.flat_map do |item, index| + if indentation.empty? + item.to_tree(indentation + ' ') + elsif index.zero? + item.to_tree(indentation + '+ ') + elsif index == @list.length - 1 + item.to_tree(indentation + '\\ ') + else + item.to_tree(indentation + '| ') + end + end + end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize + + def owner + owners.first + end + + # Owners are ordered by the amount of occurrences + def owners + all_owners.group_by(&:itself).sort_by do |_owner, occurrences| + -occurrences.count + end.map(&:first) + end + + def subgroups_owned_by(owner) + @list.flat_map do |item| + next unless item.is_a?(Group) + + a = [] + a << item if item.owner == owner + a += item.subgroups_owned_by(owner) + a + end.compact + end + + def title + @list.first.to_s + end + + def create_subgroup + group = self.class.new + group.parent = self + @list << group + group + end + + def add(line) + line.parent = self + @list << line + end + + def insert(line) + line.parent = self + index = insert_at_index(line) + @list.insert(index, line) + end + + def remove(line) + @list.safe_delete(line) + remove! unless any? { |object| object.is_a? Pattern } + end + + def remove! + @list.clear + parent&.remove(self) + self.parent = nil + end + + def ==(other) + other.is_a?(Group) && other.list == list + end + + protected + + attr_accessor :list + + private + + def all_owners + flat_map do |item| + item.owners if item.pattern? + end.compact + end + + # rubocop:disable Metrics/AbcSize + def insert_at_index(line) + new_patterns_sorted = @list.grep(Pattern).dup.push(line).sort + previous_line_index = new_patterns_sorted.index { |l| l.equal? line } - 1 + previous_line = new_patterns_sorted[previous_line_index] + padding = previous_line.pattern.size + previous_line.whitespace - line.pattern.size + line.whitespace = [1, padding].max + + if previous_line_index >= 0 + @list.index { |l| l.equal? previous_line } + 1 + else + find_last_line_of_initial_comments + end + end + # rubocop:enable Metrics/AbcSize + + def find_last_line_of_initial_comments + @list.each_with_index do |item, index| + return index unless item.is_a?(Comment) + end + 0 + end + end + end +end diff --git a/lib/codeowners/checker/group/comment.rb b/lib/codeowners/checker/group/comment.rb new file mode 100644 index 0000000..84cd674 --- /dev/null +++ b/lib/codeowners/checker/group/comment.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'line' + +module Codeowners + class Checker + class Group + # Define and manage comment line. + class Comment < Line + # Matches if the line is a comment. + # @return [Boolean] if the line start with `#` + def self.match?(line) + line.start_with?('#') + end + + # Return the comment level if the comment works like a markdown + # headers. + # @return [Integer] with the heading level. + # + # @example + # Comment.new('# First level').level # => 1 + # Comment.new('## Second').level # => 2 + def level + (@line[/^#+/] || '').size + end + end + end + end +end + +require_relative 'group_begin_comment' +require_relative 'group_end_comment' diff --git a/lib/codeowners/checker/group/empty.rb b/lib/codeowners/checker/group/empty.rb new file mode 100644 index 0000000..17a8cdf --- /dev/null +++ b/lib/codeowners/checker/group/empty.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative 'line' + +module Codeowners + class Checker + class Group + # Define line type empty line. + class Empty < Line + def self.match?(line) + line.empty? + end + end + end + end +end diff --git a/lib/codeowners/checker/group/group_begin_comment.rb b/lib/codeowners/checker/group/group_begin_comment.rb new file mode 100644 index 0000000..46f4409 --- /dev/null +++ b/lib/codeowners/checker/group/group_begin_comment.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative 'comment' + +module Codeowners + class Checker + class Group + # Define line type GroupBeginComment which is used for defining the beggining + # of a group. + class GroupBeginComment < Comment + def self.match?(line) + line.lstrip =~ /^#+ BEGIN/ + end + end + end + end +end diff --git a/lib/codeowners/checker/group/group_end_comment.rb b/lib/codeowners/checker/group/group_end_comment.rb new file mode 100644 index 0000000..9162b57 --- /dev/null +++ b/lib/codeowners/checker/group/group_end_comment.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative 'comment' + +module Codeowners + class Checker + class Group + # Define line type GroupEndComment which is used for defining the end + # of a group. + class GroupEndComment < Comment + def self.match?(line) + line.lstrip =~ /^#+ END/ + end + end + end + end +end diff --git a/lib/codeowners/checker/group/line.rb b/lib/codeowners/checker/group/line.rb index 266eeee..c6d9de6 100644 --- a/lib/codeowners/checker/group/line.rb +++ b/lib/codeowners/checker/group/line.rb @@ -1,12 +1,69 @@ # frozen_string_literal: true -# monkeypatch class to include line number +require 'pathname' module Codeowners class Checker class Group + # It sorts lines from CODEOWNERS file to different line types and holds + # shared methods for all lines. class Line - attr_accessor :line_number + attr_accessor :parent, :line_number + + def self.build(line) + subclasses.each do |klass| + return klass.new(line) if klass.match?(line) + end + UnrecognizedLine.new(line) + end + + def self.subclasses + [Empty, GroupBeginComment, GroupEndComment, Comment, Pattern] + end + + def initialize(line) + @line = line + end + + def to_s + @line + end + + def to_content + to_s + end + + def to_file + to_s + end + + def pattern? + is_a?(Pattern) + end + + def to_tree(indentation) + indentation + to_s + end + + def remove! + parent&.remove(self) + self.parent = nil + end + + def ==(other) + return false unless other.is_a?(self.class) + + other.to_s == to_s + end + + def <=>(other) + to_s <=> other.to_s + end end end end end + +require_relative 'empty' +require_relative 'comment' +require_relative 'pattern' +require_relative 'unrecognized_line' diff --git a/lib/codeowners/checker/group/pattern.rb b/lib/codeowners/checker/group/pattern.rb new file mode 100644 index 0000000..0b4e09d --- /dev/null +++ b/lib/codeowners/checker/group/pattern.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'line' +require_relative '../owner' +require 'pathspec' + +module Codeowners + class Checker + class Group + # Defines and manages line type pattern. + # Parse the line into pattern, owners and whitespaces. + class Pattern < Line + attr_accessor :owners, :whitespace + attr_reader :pattern, :spec + + def self.match?(line) + _pattern, *owners = line.split(/\s+/) + Owner.valid?(*owners) + end + + def initialize(line) + super + parse(line) + end + + def owner + owners.first + end + + def rename_owner(owner, new_owner) + owners.delete(owner) + owners << new_owner unless owners.include?(new_owner) + end + + # Parse the line counting whitespaces between pattern and owners. + def parse(line) + @pattern, *@owners = line.split(/\s+/) + @whitespace = line.split('@').first.count(' ') - 1 + @spec = parse_spec(@pattern) + end + + def match_file?(file) + spec.match file + end + + def pattern=(new_pattern) + @whitespace += @pattern.size - new_pattern.size + @whitespace = 1 if @whitespace < 1 + + @spec = parse_spec(new_pattern) + @pattern = new_pattern + end + + # @return String with the pattern and owners + # Use @param preserve_whitespaces to keep the previous identation. + def to_file(preserve_whitespaces: true) + line = pattern + spaces = preserve_whitespaces ? whitespace : 0 + line << ' ' * spaces + [line, *owners].join(' ') + end + + def to_s + to_file(preserve_whitespaces: false) + end + + def parse_spec(pattern) + PathSpec.from_lines(pattern) + end + end + end + end +end diff --git a/lib/codeowners/checker/group/unrecognized_line.rb b/lib/codeowners/checker/group/unrecognized_line.rb new file mode 100644 index 0000000..1e9e0e2 --- /dev/null +++ b/lib/codeowners/checker/group/unrecognized_line.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative 'line' + +module Codeowners + class Checker + class Group + # Hold lines which are not defined in other line classes. + class UnrecognizedLine < Line + end + end + end +end diff --git a/lib/codeowners/checker/line_grouper.rb b/lib/codeowners/checker/line_grouper.rb new file mode 100644 index 0000000..14a62d1 --- /dev/null +++ b/lib/codeowners/checker/line_grouper.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Codeowners + class Checker + # Create groups and subgroups structure for the lines in the CODEOWNERS file. + class LineGrouper + def self.call(group, lines) + new(group, lines).call + end + + def initialize(group, lines) + @group_buffer = [group] + @lines = lines + end + + def call + lines.each_with_index do |line, index| + case line + when Codeowners::Checker::Group::Empty + ensure_groups_structure + when Codeowners::Checker::Group::GroupBeginComment + trim_groups(line.level) + create_groups_structure(line.level) + when Codeowners::Checker::Group::GroupEndComment + trim_subgroups(line.level) + create_groups_structure(line.level) + when Codeowners::Checker::Group::Comment + if previous_line_empty?(index) + trim_groups(line.level) + else + trim_subgroups(line.level) + end + create_groups_structure(line.level) + when Codeowners::Checker::Group::Pattern + if new_owner?(line, index) + trim_groups(current_level) + new_group + end + ensure_groups_structure + when Codeowners::Checker::Group::UnrecognizedLine + ensure_groups_structure + else + raise StandardError, "Do not know how to handle line: #{line.inspect}" + end + current_group.add(line) + end + group_buffer.first + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/MethodLength + + private + + attr_reader :group_buffer, :lines + + def previous_line_empty?(index) + index.positive? && lines[index - 1].is_a?(Codeowners::Checker::Group::Empty) + end + + def new_owner?(line, index) # rubocop:disable Metrics/MethodLength + if previous_line_empty?(index) + offset = 2 + while (index - offset).positive? + case lines[index - offset] + when Codeowners::Checker::Group::GroupEndComment + nil + when Codeowners::Checker::Group::Comment + return false + when Codeowners::Checker::Group::Pattern + return line.owner != lines[index - offset].owner + end + offset += 1 + end + end + false + end + + def current_group + group_buffer.last + end + + def current_level + group_buffer.length - 1 + end + + def new_group + group = current_group.create_subgroup + group_buffer << group + end + + def ensure_groups_structure + new_group if current_level.zero? + end + + def create_groups_structure(level) + new_group while current_level < level + end + + def trim_groups(level) + group_buffer.slice!(level..-1) + end + + def trim_subgroups(level) + trim_groups(level + 1) + end + end + end +end diff --git a/lib/codeowners/checker/owner.rb b/lib/codeowners/checker/owner.rb new file mode 100644 index 0000000..59e7db7 --- /dev/null +++ b/lib/codeowners/checker/owner.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Codeowners + class Checker + # Owner shared methods. + module Owner + def self.valid?(*owners) + owners.any? && owners.all? { |owner| owner.include?('@') } + end + end + end +end diff --git a/spec/lib/codeowners/checker/group/comment_spec.rb b/spec/lib/codeowners/checker/group/comment_spec.rb new file mode 100644 index 0000000..0232943 --- /dev/null +++ b/spec/lib/codeowners/checker/group/comment_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'codeowners/checker/group/comment' + +RSpec.describe Codeowners::Checker::Group::Comment do + describe '.level' do + subject { described_class.build(line).level } + + { + '# Comment' => 1, + '## Comment2' => 2, + '###' => 3 + }.each do |comment, level| + context "when the comment is #{comment}" do + let(:line) { comment } + + it { is_expected.to eq(level) } + end + end + end + + describe '#match?' do + subject { match?(line) } + + context 'with valid comment' do + it { expect(described_class).to be_match('# header') } + it { expect(described_class).to be_match('## sub-header') } + end + + context 'with invalid comment' do + it { expect(described_class).not_to be_match(' # starting with space') } + end + end +end diff --git a/spec/lib/codeowners/checker/group/line_spec.rb b/spec/lib/codeowners/checker/group/line_spec.rb new file mode 100644 index 0000000..50b331a --- /dev/null +++ b/spec/lib/codeowners/checker/group/line_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'codeowners/checker/group/line' + +RSpec.describe Codeowners::Checker::Group::Line do + describe '.build' do + subject { described_class.build(line) } + + { + '# Comment' => Codeowners::Checker::Group::Comment, + '## Comment' => Codeowners::Checker::Group::Comment, + '' => Codeowners::Checker::Group::Empty, + '# BEGIN' => Codeowners::Checker::Group::GroupBeginComment, + '## BEGIN' => Codeowners::Checker::Group::GroupBeginComment, + '# END' => Codeowners::Checker::Group::GroupEndComment, + '## END' => Codeowners::Checker::Group::GroupEndComment, + 'pattern @owner' => Codeowners::Checker::Group::Pattern, + 'pattern @owner @owner1 @owner2' => Codeowners::Checker::Group::Pattern, + 'unrecognized_line' => Codeowners::Checker::Group::UnrecognizedLine + }.each do |content, klass| + context "when the line is #{content.inspect}" do + let(:line) { content } + + it { is_expected.to be_an_instance_of(klass) } + end + end + end + + describe '.subclasses' do + let(:line_subclasses) do + ObjectSpace.each_object(::Class).select { |klass| klass < described_class } - [ + Codeowners::Checker::Group::UnrecognizedLine + ] + end + + it 'includes all subclasses except of unrecognized line' do + expect(described_class.subclasses).to match_array(line_subclasses) + end + end + + describe '#to_s' do + subject { described_class.build(line).to_s } + + { + '# Comment' => '# Comment', + '## BEGIN' => '## BEGIN', + '# END' => '# END', + 'pattern @owner @owner1' => 'pattern @owner @owner1', + '' => '' + }.each do |content, string| + context "when the line is #{content.inspect}" do + let(:line) { content } + + it { is_expected.to eq(string) } + end + end + end +end diff --git a/spec/lib/codeowners/checker/group/pattern_spec.rb b/spec/lib/codeowners/checker/group/pattern_spec.rb new file mode 100644 index 0000000..3089856 --- /dev/null +++ b/spec/lib/codeowners/checker/group/pattern_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +# Pattern specification described by gitscm +# https://git-scm.com/docs/gitignore + +require 'codeowners/checker/group/pattern' + +RSpec.describe Codeowners::Checker::Group::Pattern do + subject(:pattern) { described_class.build(line) } + + describe '#owner' do + let(:line) { 'pattern @owner @owner1 @owner2' } + + it 'returns the first owner' do + expect(pattern.owner).to eq('@owner') + end + end + + describe '#match_file?' do + # An asterisk "*" matches anything except a slash. The character "?" + # matches any one character except "/". The range notation, e.g. [a-zA-Z], + # can be used to match one of the characters in a range. + # See fnmatch(3) and the FNM_PATHNAME flag for a more detailed description. + + context 'with a single asterix' do + let(:line) { '* @owner' } + + it { is_expected.to be_match_file('.file.rb') } + it { is_expected.to be_match_file('dir/.file.rb') } + it { is_expected.to be_match_file('dir/subdir/file.rb') } + end + + context 'with dir/* @owner @owner2' do + let(:line) { 'dir/* @owner @owner2' } + + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.not_to be_match_file('dir/subdir/file.rb') } + end + + context 'with dir/*/file.rb @owner' do + let(:line) { 'dir/*/file.rb @owner' } + + it { is_expected.to be_match_file('dir/subdir/file.rb') } + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.not_to be_match_file('dir/file.rb') } + it { is_expected.not_to be_match_file('dir/subdir/after/file.rb') } + end + + context 'with /dir/subdir/*file* @owner' do + let(:line) { '/dir/subdir/*file* @owner' } + + it { is_expected.to be_match_file('dir/subdir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/other_file.rb') } + it { is_expected.not_to be_match_file('dir/file.rb') } + it { is_expected.not_to be_match_file('before/dir/subdir/other_file.rb') } + it { is_expected.not_to be_match_file('dir/subdir/after/file.rb') } + end + + context 'with *.js @owner' do + let(:line) { '*.js @owner' } + + it { is_expected.to be_match_file('file.js') } + it { is_expected.to be_match_file('dir/file.js') } + it { is_expected.to be_match_file('dir/subdir/other_file.js') } + it { is_expected.to be_match_file('dir/subdir/after/file.js') } + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.not_to be_match_file('dir/subdir/file.rb') } + end + + context 'with ?ile[a-z1-9].rb @owner' do + let(:line) { '?ile[a-z1-9].rb @owner' } + + it { is_expected.to be_match_file('file1.rb') } + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.not_to be_match_file('file-a.rb') } + it { is_expected.not_to be_match_file('other_file1.rb') } + end + + context 'with dir/** @owner' do + let(:line) { 'dir/** @owner' } + + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/after/file.rb') } + end + + # Two consecutive asterisks ("**") in patterns matched against full + # pathname may have special meaning: + + # A leading "**" followed by a slash means match in all directories. + # For example, "**/foo" matches file or directory "foo" anywhere, the + # same as pattern "foo". "**/foo/bar" matches file or directory "bar" + # anywhere that is directly under directory "foo". + + context 'with **/dir/file.rb @owner' do + let(:line) { '**/dir/file.rb @owner' } + + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.to be_match_file('before/dir/file.rb') } + it { is_expected.to be_match_file('root/before/dir/file.rb') } + it { is_expected.not_to be_match_file('file.rb') } + end + + context 'with **.js @owner' do + let(:line) { '**.js @owner' } + + it { is_expected.to be_match_file('dir/file.js') } + it { is_expected.to be_match_file('dir/subdir/other_file.js') } + it { is_expected.to be_match_file('dir/subdir/after/file.js') } + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.not_to be_match_file('dir/subdir/file.rb') } + end + + context 'with ** @owner' do + let(:line) { '** @owner' } + + it { is_expected.to be_match_file('.file.rb') } + it { is_expected.to be_match_file('directory/.file.rb') } + it { is_expected.to be_match_file('directory/subdirectory/file.rb') } + end + + # A trailing "/**" matches everything inside. For example, "abc/**" + # matches all files inside directory "abc", relative to the location + # of the .gitignore file, with infinite depth. + + context 'with dir/** @owner' do + let(:line) { 'dir/** @owner' } + + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/file.js') } + it { is_expected.not_to be_match_file('file.rb') } + it { is_expected.not_to be_match_file('oher/file.rb') } + end + + # A slash followed by two consecutive asterisks then a slash matches + # zero or more directories. + # For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on. + + context 'with dir/**/file.rb @owner' do + let(:line) { 'dir/**/file.rb @owner' } + + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/after/file.rb') } + end + + context 'with dir @owner' do + let(:line) { 'dir @owner' } + + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.to be_match_file('dir/real_sub/file.rb') } + it { is_expected.not_to be_match_file('file.rb') } + end + + context 'with dir/ @owner' do + let(:line) { 'dir/ @owner' } + + it { is_expected.to be_match_file('dir/file.rb') } + it { is_expected.to be_match_file('dir/subdir/file.rb') } + it { is_expected.not_to be_match_file('file.rb') } + end + end + + describe '#pattern=' do + context 'when have whitespaces' do + let(:line) { 'pattern @owner' } + + it 'recalculates whitespaces to keep the same identation' do + expect do + pattern.pattern = 'pattern2' + end.to change(subject, :whitespace).from(5).to(4) + end + + it 'keep one whitespaces case the new pattern does not fit' do + expect do + pattern.pattern = 'pattern23456789' + end.to change(pattern, :whitespace).from(5).to(1) + end + + it { expect(pattern.spec).not_to be_empty } + end + end + + describe '#rename_owner' do + let(:line) { 'pattern @owner' } + + it 'changes owner' do + expect { pattern.rename_owner('@owner', '@new_owner') } + .to change(pattern, :owner).to('@new_owner') + end + + it 'prevents duplicates' do + pattern.rename_owner('@owner', '@owner') + expect(pattern.owners).to contain_exactly('@owner') + end + end + + describe '#to_file' do + context 'when one owner' do + let(:line) { 'pattern @owner' } + + it 'converts pattern and owner to a string' do + expect(pattern.to_s).to eq('pattern @owner') + end + end + + context 'when multiple owners' do + let(:line) { 'pattern @owner @owner1 @owner2' } + + it 'converts pattern and owner to a string' do + expect(pattern.to_s).to eq('pattern @owner @owner1 @owner2') + end + end + + context 'when the line have whitespaces' do + let(:line) { 'pattern @owner' } + + it 'keeps the white spaces' do + expect(pattern.to_file).to eq(line) + end + + context 'without preserve white spaces option' do + it 'keeps the white spaces' do + expect(pattern.to_file(preserve_whitespaces: false)).to eq('pattern @owner') + end + end + end + end +end diff --git a/spec/lib/codeowners/checker/group_spec.rb b/spec/lib/codeowners/checker/group_spec.rb new file mode 100644 index 0000000..4d5fdc8 --- /dev/null +++ b/spec/lib/codeowners/checker/group_spec.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require 'codeowners/checker/group' + +RSpec.describe Codeowners::Checker::Group do + subject { described_class.new } + + let(:comments_group) { described_class.new } + let(:group1) { described_class.new } + let(:no_name) { described_class.new } + let(:group2) { described_class.new } + let(:group3) { described_class.new } + let(:group31) { described_class.new } + let(:pattern) { Codeowners::Checker::Group::Line.build('pattern4 @owner') } + let(:pattern1) { Codeowners::Checker::Group::Line.build('pattern @owner3') } + + let(:example_content) do + [ + '#comment1', + '#comment2', + '', + '', + '#group1', + 'pattern1 @owner', + 'pattern2 @owner', + 'pattern5 @owner', + '', + 'pattern10 @owner2', + 'pattern11 @owner2', + '', + '#group2', + 'pattern4 @owner', + 'pattern5 @owner2', + 'pattern6 @owner @owner2', + '', + '# BEGIN group 3', + '#comment3', + '', + '##group3.1', + 'pattern7 @owner3', + '', + 'pattern71 @owner2', + '', + '##group3.2', + 'pattern8 @owner', + '', + 'pattern9 @owner', + '', + '# END group 3' + ] + end + + def add_content(group, text) + group.add(Codeowners::Checker::Group::Line.build(text)) + end + + before do + add_content(comments_group, '#comment1') + add_content(comments_group, '#comment2') + add_content(comments_group, '') + add_content(comments_group, '') + subject.add(comments_group) + + add_content(group1, '#group1') + add_content(group1, 'pattern1 @owner') + add_content(group1, 'pattern2 @owner') + add_content(group1, 'pattern5 @owner') + add_content(group1, '') + subject.add(group1) + + add_content(no_name, 'pattern10 @owner2') + add_content(no_name, 'pattern11 @owner2') + add_content(no_name, '') + subject.add(no_name) + + add_content(group2, '#group2') + add_content(group2, 'pattern4 @owner') + add_content(group2, 'pattern5 @owner2') + add_content(group2, 'pattern6 @owner @owner2') + add_content(group2, '') + subject.add(group2) + + add_content(group3, '# BEGIN group 3') + add_content(group3, '#comment3') + add_content(group3, '') + add_content(group31, '##group3.1') + add_content(group31, 'pattern7 @owner3') + add_content(group31, '') + group3.add(group31) + group3_no_name = described_class.new + add_content(group3_no_name, 'pattern71 @owner2') + add_content(group3_no_name, '') + group3.add(group3_no_name) + group32 = described_class.new + add_content(group32, '##group3.2') + add_content(group32, 'pattern8 @owner') + add_content(group32, '') + add_content(group32, 'pattern9 @owner') + add_content(group32, '') + group3.add(group32) + add_content(group3, '# END group 3') + subject.add(group3) + end + + describe '#parse' do + let(:lines) { [] } + let(:main_group) { described_class.new } + + before do + example_content.each { |text| lines << Codeowners::Checker::Group::Line.build(text) } + end + + it 'parses lines from codeowners file to groups and subgroups' do + main_group.parse(lines) + expect(main_group).to eq(subject) + expect(main_group.to_content).to eq(example_content) + end + end + + describe '#to_content' do + it 'dumps the group to content' do + expect(subject.to_content).to eq(example_content) + end + end + + describe '#to_tree' do + it 'maps the structure of the groups and subgroups into strings' do + expect(group3.to_tree).to eq( + [ + ' # BEGIN group 3', + ' #comment3', + ' ', + ' + ##group3.1', + ' | pattern7 @owner3', + ' \\ ', + ' + pattern71 @owner2', + ' \\ ', + ' + ##group3.2', + ' | pattern8 @owner', + ' | ', + ' | pattern9 @owner', + ' \\ ', + ' # END group 3' + ] + ) + end + end + + describe '#owner' do + it 'returns the first owner' do + expect(group1.owner).to eq('@owner') + expect(group3.owner).to eq('@owner') + expect(group31.owner).to eq('@owner3') + end + end + + describe '#owners' do + it 'returns owners ordered by the amount of occurences' do + expect(group1.owners).to match_array(['@owner']) + expect(group3.owners).to match_array(['@owner', '@owner2', '@owner3']) + end + end + + describe '#subgroups_owned_by' do + context 'when subgroups owned by desired owner exist' do + it 'returns array of subgroups owned by owner' do + subgroups = subject.subgroups_owned_by('@owner') + expect(subgroups.map(&:title)).to eq(['#group1', '#group2', '# BEGIN group 3', '##group3.2']) + end + end + + context 'when no subgroup owned by desired owner exists' do + it 'returns an ampty array' do + subgroups = subject.subgroups_owned_by('@owner4') + expect(subgroups.map(&:title)).to eq([]) + end + end + + context 'when a pattern has been added to the end of the main_group' do + before { subject.add(pattern) } + + it 'returns array of subgroups owned by owner' do + subgroups = subject.subgroups_owned_by('@owner') + expect(subgroups.map(&:title)).to eq(['#group1', '#group2', '# BEGIN group 3', '##group3.2']) + end + end + end + + describe '#title' do + it 'returns the title of the group' do + expect(group1.title).to eq('#group1') + expect(group3.title).to eq('# BEGIN group 3') + expect(no_name.title).to eq('pattern10 @owner2') + end + end + + describe '#add' do + it 'adds new line to the group' do + group1.add(pattern) + expect(group1.to_content).to eq( + [ + '#group1', + 'pattern1 @owner', + 'pattern2 @owner', + 'pattern5 @owner', + '', + 'pattern4 @owner' + ] + ) + end + end + + describe '#insert' do + context 'without a subgroup' do + context 'when in the middle of the group' do + it 'inserts new pattern to the group in alphabetical order' do + group1.insert(pattern) + expect(group1.to_content).to eq( + [ + '#group1', + 'pattern1 @owner', + 'pattern2 @owner', + 'pattern4 @owner', + 'pattern5 @owner', + '' + ] + ) + end + end + + context 'with initial comments' do + it 'inserts new pattern to the first row' do + group1.insert(pattern1) + expect(group1.to_content).to eq( + [ + '#group1', + 'pattern @owner3', + 'pattern1 @owner', + 'pattern2 @owner', + 'pattern5 @owner', + '' + ] + ) + end + end + + context 'without initial comments' do + it 'inserts the pattern in the first row' do + no_name.insert(pattern1) + expect(no_name.to_content).to eq( + ['pattern @owner3', 'pattern10 @owner2', 'pattern11 @owner2', ''] + ) + end + end + end + + context 'when inserting in a group with a subgroup' do + it 'inserts new pattern to the main group' do + group3.insert(pattern) + expect(group3.to_content).to eq( + [ + '# BEGIN group 3', + '#comment3', + 'pattern4 @owner', + '', + '##group3.1', + 'pattern7 @owner3', + '', + 'pattern71 @owner2', + '', + '##group3.2', + 'pattern8 @owner', + '', + 'pattern9 @owner', + '', + '# END group 3' + ] + ) + end + end + end + + describe '#remove' do + let(:group4) { described_class.new } + let(:comment) { Codeowners::Checker::Group::Line.build('#comment') } + let(:unrecognized_line) { Codeowners::Checker::Group::Line.build('unrecognized_line') } + let(:empty) { Codeowners::Checker::Group::Line.build('') } + + context 'when the group contains more than one patterns' do + before do + add_content(group4, '#Group4') + group4.add(comment) + group4.add(pattern) + group4.add(unrecognized_line) + add_content(group4, 'pattern5 @owner') + group4.add(empty) + subject.add(group4) + end + + it 'removes pattern from the group' do + group4.remove(pattern) + expect(group4.to_content).to eq( + ['#Group4', '#comment', 'unrecognized_line', 'pattern5 @owner', ''] + ) + end + + it 'removes comment from the group' do + group4.remove(comment) + expect(group4.to_content).to eq( + ['#Group4', 'pattern4 @owner', 'unrecognized_line', 'pattern5 @owner', ''] + ) + end + + it 'removes empty line from the group' do + group4.remove(empty) + expect(group4.to_content).to eq( + ['#Group4', '#comment', 'pattern4 @owner', 'unrecognized_line', 'pattern5 @owner'] + ) + end + + it 'removes unrecognized line from the group' do + group4.remove(unrecognized_line) + expect(group4.to_content).to eq( + ['#Group4', '#comment', 'pattern4 @owner', 'pattern5 @owner', ''] + ) + end + end + + context 'when there is only one pattern in the group' do + let(:group41) { described_class.new } + + before do + add_content(group4, '#Group4') + group4.add(pattern) + group4.add(empty) + subject.add(group4) + add_content(group41, '##Group4_1') + group41.add(pattern1) + group4.add(group41) + end + + it 'removes the pattern, the title and the group from the parent group' do + group41.remove(pattern1) + expect(group41.to_content).to eq([]) + expect(group41.parent).to eq(nil) + expect(group4.to_content).to eq(['#Group4', 'pattern4 @owner', '']) + end + end + end +end