codeowners-checker 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9768a17e8e26a830efa9f9808aec4a6e2fad800b1da0e7223fa68b8ee1a4c31c
4
- data.tar.gz: 0e744a329069f56f24a1ba922c1c614eef6667816de05d6b042f2c77a883b559
3
+ metadata.gz: 94005a8b1e6eae5bf06a313f752d8adc3aa060d452487f20f22d8e1bef3dddb3
4
+ data.tar.gz: f02eab71b3bbb472c9ee3ca3d721dcecd611cd55b0a8ab5746f9606b8d6f4c4b
5
5
  SHA512:
6
- metadata.gz: 02bcb19b84e96d42aedb93253baaedc4ead716c259dfc811f76d2bc75285c5a97ce69cffd4d88f82a9b5fa2891409337158fc0ba7fbef7090a55eaf9d722f41b
7
- data.tar.gz: 2d63073791a7fa205e3aee026158fd37841217316c0a6bbbdad5c75f0ac73d9d9f3ab6858296c918cfc167654ca2d3778db4dc29822b1dbc98f16ebe21ea4c33
6
+ metadata.gz: 71ab0e69066731091f2ac51afb108a1ba67b17dc6e0d7693a4227ea9964c46d7833c9ab1e89cb822187d1f05fc70a39541aaa0a9e3d30c18bd2c2ee04d5431e8
7
+ data.tar.gz: 839ea4f56af40e7dfdc9ca452c6caddd1ea4ee0d6bc5884f5dd37f7ead3fc2a89739ac8ae01279c5e1518d95feba6df02a4209607648f5051bc5508d3d480ad2
data/README.md CHANGED
@@ -9,7 +9,6 @@ between two git revisions.
9
9
 
10
10
  ## Usage
11
11
 
12
-
13
12
  ### Configure
14
13
 
15
14
  $ codeowners-checker config owner <@owner>
@@ -114,6 +113,9 @@ the comments defining the group are deleted as well as the pattern.
114
113
 
115
114
  ![Useless pattern example](demos/useless_pattern.svg)
116
115
 
116
+ You can also use [fzf](https://github.com/junegunn/fzf) to pick better results
117
+ and interactively choose the right file.
118
+
117
119
  Invalid patterns were fixed and the group `Security` was removed when deleting the only pattern
118
120
  in the group:
119
121
  ```
@@ -3,19 +3,19 @@
3
3
  lib = File.expand_path('lib', __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
  require 'codeowners/checker/version'
6
+ require 'codeowners/cli/suggest_file_from_pattern'
6
7
 
7
8
  Gem::Specification.new do |spec|
8
9
  spec.name = 'codeowners-checker'
9
10
  spec.version = Codeowners::Checker::VERSION
10
11
  spec.authors = ['Jônatas Davi Paganini', 'Eva Kadlecová', 'Michal Papis']
11
12
  spec.email = ['open-source@toptal.com']
13
+ spec.homepage = 'https://github.com/toptal/codeowners-checker'
12
14
 
13
15
  spec.summary = 'Check consistency of Github CODEOWNERS and git changes.'
14
16
  spec.license = 'MIT'
15
17
 
16
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
- f.match(%r{^(test|spec|features)/})
18
- end
18
+ spec.files = Dir['codeowners-checker.gemspec', '*.{md,txt}', 'lib/**/*.rb']
19
19
  spec.bindir = 'bin'
20
20
  spec.executables = ['codeowners-checker']
21
21
  spec.require_paths = ['lib']
@@ -31,4 +31,12 @@ Gem::Specification.new do |spec|
31
31
  spec.add_development_dependency 'rubocop', '~> 0.61.1'
32
32
  spec.add_development_dependency 'rubocop-rspec', '~> 1.30'
33
33
  spec.add_development_dependency 'simplecov', '~> 0.16.1'
34
+
35
+ unless ENV['TRAVIS']
36
+ unless Codeowners::Cli::SuggestFileFromPattern.installed_fzf?
37
+ spec.post_install_message = <<~MESSAGE
38
+ Please, install `fzf` for a better experience.
39
+ MESSAGE
40
+ end
41
+ end
34
42
  end
data/issues.md ADDED
@@ -0,0 +1,29 @@
1
+ problems
2
+
3
+ - linked lines groups serve to attach or dettach specific info into a chunk of text
4
+ - linked lines belong to a group and group belong to file
5
+
6
+
7
+ what we're trying to solve
8
+
9
+ 1. we have a file that have a tree structure without holding the tree
10
+ CodeOwners holds the flat structure
11
+
12
+ 2. we need to interpret and parse the tree structure from the file
13
+ Group holds the tree structure with LineGroupers and pure lines
14
+
15
+ 3. we need to iterate over the lines to verify the file consistency
16
+ Checker do that.
17
+
18
+ 3.1 we interact with user when find similar and duplicated lines
19
+
20
+ 4. we need to remove a line and remove a group if the line was the last pattern
21
+ in the group.
22
+ - the Line knows how to remove them selves.
23
+
24
+ 5. we need to add or change lines
25
+ - the Group knows what file it belongs and allow to insert a pattern in the
26
+ middle or in the bottom of the file
27
+
28
+
29
+
@@ -34,11 +34,8 @@ module Codeowners
34
34
  changes_to_analyze.select { |_k, v| v == 'A' }.keys
35
35
  end
36
36
 
37
- def check!
38
- {
39
- missing_ref: missing_reference,
40
- useless_pattern: useless_pattern
41
- }
37
+ def fix!
38
+ catch(:user_quit) { results }
42
39
  end
43
40
 
44
41
  def changes_for_patterns(patterns)
@@ -82,7 +79,7 @@ module Codeowners
82
79
  end
83
80
 
84
81
  def pattern_has_files(pattern)
85
- @git.ls_files(pattern).any?
82
+ @git.ls_files(pattern.gsub(%r{^/}, '')).any?
86
83
  end
87
84
 
88
85
  def defined_owner?(file)
@@ -107,17 +104,29 @@ module Codeowners
107
104
  codeowners.main_group
108
105
  end
109
106
 
107
+ def consistent?
108
+ results.values.all?(&:empty?)
109
+ end
110
+
110
111
  def commit_changes!
111
112
  @git.add(codeowners_filename)
112
113
  @git.commit('Fix pattern :robot:')
113
114
  end
114
115
 
115
- private
116
-
117
116
  def codeowners_filename
118
117
  directories = ['', '.github', 'docs', '.gitlab']
119
118
  paths = directories.map { |dir| File.join(@repo_dir, dir, 'CODEOWNERS') }
120
119
  Dir.glob(paths).first || paths.first
121
120
  end
121
+
122
+ private
123
+
124
+ def results
125
+ @results ||=
126
+ {
127
+ missing_ref: missing_reference,
128
+ useless_pattern: useless_pattern
129
+ }
130
+ end
122
131
  end
123
132
  end
@@ -20,7 +20,7 @@ module Codeowners
20
20
  end
21
21
 
22
22
  def persist!
23
- file_manager.content = to_content
23
+ file_manager.content = main_group.to_file
24
24
  end
25
25
 
26
26
  def main_group
@@ -9,12 +9,15 @@ module Codeowners
9
9
  @target_dir, = File.split(@file)
10
10
  end
11
11
 
12
+ # @return <Array> of lines chomped
12
13
  def content
13
14
  @content ||= File.readlines(@file).map(&:chomp)
14
15
  rescue Errno::ENOENT
15
16
  @content = []
16
17
  end
17
18
 
19
+ # Save content to the @file
20
+ # Creates the directory of the file if needed
18
21
  def content=(content)
19
22
  @content = content
20
23
 
@@ -25,7 +25,7 @@ module Codeowners
25
25
  if object.is_a?(Group)
26
26
  object.each(&block)
27
27
  else
28
- block.call(object)
28
+ yield(object)
29
29
  end
30
30
  end
31
31
  end
@@ -38,8 +38,14 @@ module Codeowners
38
38
  @list.flat_map(&:to_content)
39
39
  end
40
40
 
41
+ def to_file
42
+ @list.flat_map(&:to_file)
43
+ end
44
+
41
45
  # Returns an array of strings representing the structure of the group.
42
46
  # It indent internal subgroups for readability and debugging purposes.
47
+ # rubocop:disable Metrics/MethodLength
48
+ # rubocop:disable Metrics/AbcSize
43
49
  def to_tree(indentation = '')
44
50
  @list.each_with_index.flat_map do |item, index|
45
51
  if indentation.empty?
@@ -53,6 +59,8 @@ module Codeowners
53
59
  end
54
60
  end
55
61
  end
62
+ # rubocop:enable Metrics/MethodLength
63
+ # rubocop:enable Metrics/AbcSize
56
64
 
57
65
  def owner
58
66
  owners.first
@@ -67,7 +75,7 @@ module Codeowners
67
75
 
68
76
  def subgroups_owned_by(owner)
69
77
  @list.flat_map do |item|
70
- return [] unless item.is_a?(Group)
78
+ next unless item.is_a?(Group)
71
79
 
72
80
  a = []
73
81
  a << item if item.owner == owner
@@ -110,7 +118,7 @@ module Codeowners
110
118
  end
111
119
 
112
120
  def ==(other)
113
- other.kind_of?(Group) && other.list == list
121
+ other.is_a?(Group) && other.list == list
114
122
  end
115
123
 
116
124
  protected
@@ -125,17 +133,21 @@ module Codeowners
125
133
  end.compact
126
134
  end
127
135
 
136
+ # rubocop:disable Metrics/AbcSize
128
137
  def insert_at_index(line)
129
- patterns = @list.grep(Pattern)
130
- new_patterns_sorted = patterns.dup.push(line).sort
131
- new_pattern_index = new_patterns_sorted.index { |l| l.equal? line }
132
-
133
- if new_pattern_index > 0 # rubocop:disable Style/NumericPredicate
134
- new_pattern_index + 1
138
+ new_patterns_sorted = @list.grep(Pattern).dup.push(line).sort
139
+ previous_line_index = new_patterns_sorted.index { |l| l.equal? line } - 1
140
+ previous_line = new_patterns_sorted[previous_line_index]
141
+ padding = previous_line.pattern.size + previous_line.whitespace - line.pattern.size
142
+ line.whitespace = [1, padding].max
143
+
144
+ if previous_line_index >= 0
145
+ @list.index { |l| l.equal? previous_line } + 1
135
146
  else
136
147
  find_last_line_of_initial_comments
137
148
  end
138
149
  end
150
+ # rubocop:enable Metrics/AbcSize
139
151
 
140
152
  def find_last_line_of_initial_comments
141
153
  @list.each_with_index do |item, index|
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
3
4
  module Codeowners
4
5
  class Checker
5
6
  class Group
@@ -8,7 +9,7 @@ module Codeowners
8
9
  class Line
9
10
  attr_accessor :parent
10
11
 
11
- def self.build(line, transform_line_procs: nil)
12
+ def self.build(line)
12
13
  subclasses.each do |klass|
13
14
  return klass.new(line) if klass.match?(line)
14
15
  end
@@ -31,6 +32,10 @@ module Codeowners
31
32
  to_s
32
33
  end
33
34
 
35
+ def to_file
36
+ to_s
37
+ end
38
+
34
39
  def pattern?
35
40
  is_a?(Pattern)
36
41
  end
@@ -41,7 +46,7 @@ module Codeowners
41
46
 
42
47
  def remove!
43
48
  parent&.remove(self)
44
- parent = nil
49
+ self.parent = nil
45
50
  end
46
51
 
47
52
  def ==(other)
@@ -53,25 +58,6 @@ module Codeowners
53
58
  def <=>(other)
54
59
  to_s <=> other.to_s
55
60
  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
61
  end
76
62
  end
77
63
  end
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'line'
4
+ require_relative '../owner'
4
5
 
5
6
  module Codeowners
6
7
  class Checker
7
8
  class Group
8
9
  # Defines and manages line type pattern.
10
+ # Parse the line into pattern, owners and whitespaces.
9
11
  class Pattern < Line
10
- attr_accessor :pattern, :owners
12
+ attr_accessor :owners, :whitespace
13
+ attr_reader :pattern
11
14
 
12
15
  def self.match?(line)
13
16
  _pattern, *owners = line.split(/\s+/)
14
- owners.any? && owners.all? { |owner| owner.include?('@') }
17
+ Owner.valid?(*owners)
15
18
  end
16
19
 
17
20
  def initialize(line)
@@ -23,22 +26,38 @@ module Codeowners
23
26
  owners.first
24
27
  end
25
28
 
29
+ # Parse the line counting whitespaces between pattern and owners.
26
30
  def parse(line)
27
31
  @pattern, *@owners = line.split(/\s+/)
32
+ @whitespace = line.split('@').first.count(' ') - 1
28
33
  end
29
34
 
30
35
  def match_file?(file)
31
- regex.match(file)
36
+ if !pattern.include?('/') || pattern.include?('**')
37
+ File.fnmatch(pattern.gsub(%r{^/}, ''), file, File::FNM_DOTMATCH)
38
+ else
39
+ File.fnmatch(pattern.gsub(%r{^/}, ''), file, File::FNM_PATHNAME | File::FNM_DOTMATCH)
40
+ end
32
41
  end
33
42
 
34
- def to_s
35
- [@pattern, @owners].join(' ')
43
+ def pattern=(new_pattern)
44
+ @whitespace += @pattern.size - new_pattern.size
45
+ @whitespace = 1 if @whitespace < 1
46
+
47
+ @pattern = new_pattern
36
48
  end
37
49
 
38
- private
50
+ # @return String with the pattern and owners
51
+ # Use @param preserve_whitespaces to keep the previous identation.
52
+ def to_file(preserve_whitespaces: true)
53
+ line = pattern
54
+ spaces = preserve_whitespaces ? whitespace : 0
55
+ line << ' ' * spaces
56
+ [line, *owners].join(' ')
57
+ end
39
58
 
40
- def regex
41
- Regexp.new(pattern.gsub(%r{/\*\*}, '(/[^/]+)+').gsub(/\*/, '[^/]+'))
59
+ def to_s
60
+ to_file(preserve_whitespaces: false)
42
61
  end
43
62
  end
44
63
  end
@@ -13,6 +13,9 @@ module Codeowners
13
13
  @lines = lines
14
14
  end
15
15
 
16
+ # rubocop:disable Metrics/AbcSize
17
+ # rubocop:disable Metrics/CyclomaticComplexity
18
+ # rubocop:disable Metrics/MethodLength
16
19
  def call
17
20
  lines.each_with_index do |line, index|
18
21
  case line
@@ -46,6 +49,9 @@ module Codeowners
46
49
  end
47
50
  group_buffer.first
48
51
  end
52
+ # rubocop:enable Metrics/AbcSize
53
+ # rubocop:enable Metrics/CyclomaticComplexity
54
+ # rubocop:enable Metrics/MethodLength
49
55
 
50
56
  private
51
57
 
@@ -55,7 +61,7 @@ module Codeowners
55
61
  index.positive? && lines[index - 1].is_a?(Codeowners::Checker::Group::Empty)
56
62
  end
57
63
 
58
- def new_owner?(line, index)
64
+ def new_owner?(line, index) # rubocop:disable Metrics/MethodLength
59
65
  if previous_line_empty?(index)
60
66
  offset = 2
61
67
  while (index - offset).positive?
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ class Checker
5
+ # Owner shared methods.
6
+ module Owner
7
+ def self.valid?(*owners)
8
+ owners.any? && owners.all? { |owner| owner.include?('@') }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Codeowners
4
4
  class Checker
5
- VERSION = '1.0.0'
5
+ VERSION = '1.0.1'
6
6
  end
7
7
  end
@@ -1,29 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'fuzzy_match'
4
-
5
3
  require_relative '../checker'
6
4
  require_relative 'base'
7
5
  require_relative 'config'
8
6
  require_relative 'filter'
7
+ require_relative 'suggest_file_from_pattern'
8
+ require_relative '../checker/owner'
9
9
 
10
10
  module Codeowners
11
11
  module Cli
12
12
  # Command Line Interface used by bin/codeowners-checker.
13
- class Main < Base
13
+ class Main < Base # rubocop:disable Metrics/ClassLength
14
14
  LABEL = { missing_ref: 'Missing references', useless_pattern: 'No files matching with the pattern' }.freeze
15
15
  option :from, default: 'origin/master'
16
16
  option :to, default: 'HEAD'
17
17
  option :interactive, default: true, type: :boolean, aliases: '-i'
18
+ option :autocommit, default: false, type: :boolean, aliases: '-c'
18
19
  desc 'check REPO', 'Checks .github/CODEOWNERS consistency'
19
20
  # for pre-commit: --from HEAD --to index
20
21
  def check(repo = '.')
21
22
  @codeowners_changed = false
22
23
  @repo = repo
23
24
  setup_checker
24
- @checker.check!
25
- write_codeowners if @codeowners_changed
26
- @checker.commit_changes! if options[:interactive] && yes?('Commit changes?')
25
+ if options[:interactive]
26
+ interactive_mode
27
+ else
28
+ report_inconsistencies
29
+ end
27
30
  end
28
31
 
29
32
  desc 'filter <by-owner>', 'List owners of changed files'
@@ -34,12 +37,31 @@ module Codeowners
34
37
 
35
38
  private
36
39
 
37
- def setup_checker
40
+ def interactive_mode
41
+ @checker.fix!
42
+ return unless @codeowners_changed
43
+
44
+ write_codeowners
45
+ @checker.commit_changes! if options[:autocommit] || yes?('Commit changes?')
46
+ end
47
+
48
+ def report_inconsistencies
49
+ if @checker.consistent?
50
+ puts '✅ File is consistent'
51
+ exit 0
52
+ else
53
+ puts "File #{@checker.codeowners_filename} is inconsistent:"
54
+ report_errors!
55
+ exit(-1)
56
+ end
57
+ end
58
+
59
+ def setup_checker # rubocop:disable Metrics/AbcSize
38
60
  to = options[:to] != 'index' ? options[:to] : nil
39
61
  @checker = Codeowners::Checker.new(@repo, options[:from], to)
40
62
  @checker.when_useless_pattern = method(:suggest_fix_for)
41
63
  @checker.when_new_file = method(:suggest_add_to_codeowners)
42
- @checker.transformers << method(:unrecognized_line)
64
+ @checker.transformers << method(:unrecognized_line) if options[:interactive]
43
65
  end
44
66
 
45
67
  def write_codeowners
@@ -47,15 +69,24 @@ module Codeowners
47
69
  end
48
70
 
49
71
  def suggest_add_to_codeowners(file)
50
- return unless yes?("File added: #{file.inspect}. Add owner to the CODEOWNERS file?")
72
+ case add_to_codeowners_dialog(file)
73
+ when 'y' then add_to_codeowners(file)
74
+ when 'i' then nil
75
+ when 'q' then throw :user_quit
76
+ end
77
+ end
51
78
 
52
- owner = ask('File owner(s): ')
53
- new_line = create_new_pattern(file, owner)
79
+ def add_to_codeowners_dialog(file)
80
+ ask(<<~QUESTION, limited_to: %w[y i q])
81
+ File added: #{file.inspect}. Add owner to the CODEOWNERS file?
82
+ (y) yes
83
+ (i) ignore
84
+ (q) quit and save
85
+ QUESTION
86
+ end
54
87
 
55
- unless new_line.pattern?
56
- puts "#{owner.inspect} is not a valid owner name."
57
- return
58
- end
88
+ def add_to_codeowners(file)
89
+ new_line = create_new_pattern(file)
59
90
 
60
91
  subgroups = @checker.main_group.subgroups_owned_by(new_line.owner)
61
92
  add_pattern(new_line, subgroups)
@@ -63,14 +94,42 @@ module Codeowners
63
94
  @codeowners_changed = true
64
95
  end
65
96
 
66
- def create_new_pattern(file, owner)
67
- line = "#{file} #{owner}"
68
- Codeowners::Checker::Group::Line.build(line)
97
+ def create_new_pattern(file)
98
+ sorted_owners = @checker.main_group.owners.sort
99
+ list_owners(sorted_owners)
100
+ loop do
101
+ owner = new_owner(sorted_owners)
102
+
103
+ unless Codeowners::Checker::Owner.valid?(owner)
104
+ puts "#{owner.inspect} is not a valid owner name. Try again."
105
+ next
106
+ end
107
+
108
+ return Codeowners::Checker::Group::Pattern.new("#{file} #{owner}")
109
+ end
110
+ end
111
+
112
+ def list_owners(sorted_owners)
113
+ puts 'Owners:'
114
+ sorted_owners.each_with_index { |owner, i| puts "#{i + 1} - #{owner}" }
115
+ puts "Choose owner, add new one or leave empty to use #{@config.default_owner.inspect}."
116
+ end
117
+
118
+ def new_owner(sorted_owners)
119
+ owner = ask('New owner: ')
120
+
121
+ if owner.to_i.between?(1, sorted_owners.length)
122
+ sorted_owners[owner.to_i - 1]
123
+ elsif owner.empty?
124
+ @config.default_owner
125
+ else
126
+ owner
127
+ end
69
128
  end
70
129
 
71
130
  def add_pattern(pattern, subgroups)
72
131
  unless subgroups.empty?
73
- return if insert_pattern_into_subgroup(pattern, subgroups) == true
132
+ return if insert_pattern_into_subgroup(pattern, subgroups)
74
133
  end
75
134
 
76
135
  @checker.main_group.add(pattern) if yes?('Add to the end of the CODEOWNERS file?')
@@ -91,10 +150,10 @@ module Codeowners
91
150
  end
92
151
 
93
152
  def suggest_fix_for(line)
94
- search = FuzzyMatch.new(line.suggest_files_for_pattern)
95
- suggestion = search.find(line.pattern)
153
+ return unless options[:interactive]
96
154
 
97
155
  puts "Pattern #{line.pattern.inspect} doesn't match."
156
+ suggestion = Codeowners::Cli::SuggestFileFromPattern.new(line.pattern).pick_suggestion
98
157
 
99
158
  # TODO: Handle duplicate patterns.
100
159
  if suggestion
@@ -109,22 +168,21 @@ module Codeowners
109
168
  def apply_suggestion(line, suggestion)
110
169
  case make_suggestion(suggestion)
111
170
  when 'i' then nil
112
- when 'y'
113
- line.pattern = suggestion
114
- when 'e'
115
- pattern_change(line)
116
- when 'd'
117
- line.remove!
171
+ when 'y' then line.pattern = suggestion
172
+ when 'e' then pattern_change(line)
173
+ when 'd' then line.remove!
174
+ when 'q' then throw :user_quit
118
175
  end
119
176
  end
120
177
 
121
178
  def make_suggestion(suggestion)
122
- ask(<<~QUESTION, limited_to: %w[y i e d])
179
+ ask(<<~QUESTION, limited_to: %w[y i e d q])
123
180
  Replace with: #{suggestion.inspect}?
124
181
  (y) yes
125
182
  (i) ignore
126
183
  (e) edit the pattern
127
184
  (d) delete the pattern
185
+ (q) quit and save
128
186
  QUESTION
129
187
  end
130
188
 
@@ -133,14 +191,16 @@ module Codeowners
133
191
  when 'e' then pattern_change(line)
134
192
  when 'i' then nil
135
193
  when 'd' then line.remove!
194
+ when 'q' then throw :user_quit
136
195
  end
137
196
  end
138
197
 
139
198
  def pattern_suggest_fixing
140
- ask(<<~QUESTION, limited_to: %w[i e d])
199
+ ask(<<~QUESTION, limited_to: %w[i e d q])
141
200
  (e) edit the pattern
142
201
  (d) delete the pattern
143
202
  (i) ignore
203
+ (q) quit and save
144
204
  QUESTION
145
205
  end
146
206
 
@@ -172,12 +232,39 @@ module Codeowners
172
232
 
173
233
  def unrecognized_line_new_line
174
234
  line = nil
175
- begin
235
+ loop do
176
236
  new_line_string = ask('New line: ')
177
237
  line = Codeowners::Checker::Group::Line.build(new_line_string)
178
- end while line.is_a?(Codeowners::Checker::Group::UnrecognizedLine)
238
+ break unless line.is_a?(Codeowners::Checker::Group::UnrecognizedLine)
239
+ end
240
+ @codeowners_changed = true
179
241
  line
180
242
  end
243
+
244
+ def ask(message, *opts)
245
+ return unless options[:interactive]
246
+
247
+ super
248
+ end
249
+
250
+ def yes?(message, *opts)
251
+ return unless options[:interactive]
252
+
253
+ super
254
+ end
255
+
256
+ LABELS = {
257
+ missing_ref: 'No owner defined',
258
+ useless_pattern: 'Useless patterns'
259
+ }.freeze
260
+
261
+ def report_errors!
262
+ @checker.fix!.each do |error_type, inconsistencies|
263
+ next if inconsistencies.empty?
264
+
265
+ puts LABELS[error_type], '-' * 30, inconsistencies, '-' * 30
266
+ end
267
+ end
181
268
  end
182
269
  end
183
270
  end