quiet_quality 0.1.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/dogfood.yml +28 -0
  3. data/.github/workflows/linters.yml +31 -0
  4. data/.github/workflows/rspec.yml +33 -0
  5. data/.gitignore +6 -0
  6. data/.rspec +1 -0
  7. data/.rubocop.yml +21 -0
  8. data/Gemfile +3 -0
  9. data/LICENSE +20 -0
  10. data/README.md +34 -0
  11. data/bin/qq +35 -0
  12. data/lib/quiet_quality/annotation_locator.rb +33 -0
  13. data/lib/quiet_quality/annotators/github_stdout.rb +36 -0
  14. data/lib/quiet_quality/annotators.rb +19 -0
  15. data/lib/quiet_quality/changed_file.rb +40 -0
  16. data/lib/quiet_quality/changed_files.rb +35 -0
  17. data/lib/quiet_quality/cli/option_parser.rb +103 -0
  18. data/lib/quiet_quality/cli/options.rb +27 -0
  19. data/lib/quiet_quality/cli/options_builder.rb +49 -0
  20. data/lib/quiet_quality/cli.rb +12 -0
  21. data/lib/quiet_quality/executors/base_executor.rb +46 -0
  22. data/lib/quiet_quality/executors/concurrent_executor.rb +20 -0
  23. data/lib/quiet_quality/executors/pipeline.rb +59 -0
  24. data/lib/quiet_quality/executors/serial_executor.rb +15 -0
  25. data/lib/quiet_quality/executors.rb +17 -0
  26. data/lib/quiet_quality/message.rb +25 -0
  27. data/lib/quiet_quality/message_filter.rb +33 -0
  28. data/lib/quiet_quality/messages.rb +54 -0
  29. data/lib/quiet_quality/tool_options.rb +32 -0
  30. data/lib/quiet_quality/tools/outcome.rb +26 -0
  31. data/lib/quiet_quality/tools/rspec/parser.rb +45 -0
  32. data/lib/quiet_quality/tools/rspec/runner.rb +57 -0
  33. data/lib/quiet_quality/tools/rspec.rb +11 -0
  34. data/lib/quiet_quality/tools/rubocop/parser.rb +46 -0
  35. data/lib/quiet_quality/tools/rubocop/runner.rb +67 -0
  36. data/lib/quiet_quality/tools/rubocop.rb +11 -0
  37. data/lib/quiet_quality/tools/standardrb/parser.rb +8 -0
  38. data/lib/quiet_quality/tools/standardrb/runner.rb +11 -0
  39. data/lib/quiet_quality/tools/standardrb.rb +13 -0
  40. data/lib/quiet_quality/tools.rb +21 -0
  41. data/lib/quiet_quality/version.rb +3 -0
  42. data/lib/quiet_quality/version_control_systems/git.rb +117 -0
  43. data/lib/quiet_quality/version_control_systems.rb +8 -0
  44. data/lib/quiet_quality.rb +15 -0
  45. data/quiet_quality.gemspec +44 -0
  46. data/tmp/.gitkeep +0 -0
  47. metadata +219 -0
@@ -0,0 +1,59 @@
1
+ module QuietQuality
2
+ module Executors
3
+ class Pipeline
4
+ def initialize(tool_options:, changed_files: nil)
5
+ @tool_options = tool_options
6
+ @changed_files = changed_files
7
+ end
8
+
9
+ def tool_name
10
+ tool_options.tool_name
11
+ end
12
+
13
+ def outcome
14
+ @_outcome ||= runner.invoke!
15
+ end
16
+
17
+ def failure?
18
+ outcome.failure?
19
+ end
20
+
21
+ def messages
22
+ return @_messages if defined?(@_messages)
23
+ @_messages = parser.messages
24
+ @_messages = relevance_filter.filter(@_messages) if filter_messages? && changed_files
25
+ @_messages.each { |m| locator.update!(m) } if changed_files
26
+ @_messages
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :changed_files, :tool_options
32
+
33
+ def limit_targets?
34
+ tool_options.limit_targets?
35
+ end
36
+
37
+ def filter_messages?
38
+ tool_options.filter_messages?
39
+ end
40
+
41
+ def runner
42
+ @_runner ||= tool_options.runner_class
43
+ .new(changed_files: limit_targets? ? changed_files : nil)
44
+ end
45
+
46
+ def parser
47
+ @_parser ||= tool_options.parser_class.new(outcome.output)
48
+ end
49
+
50
+ def relevance_filter
51
+ @_relevance_filter ||= MessageFilter.new(changed_files: changed_files)
52
+ end
53
+
54
+ def locator
55
+ @_locator ||= AnnotationLocator.new(changed_files: changed_files)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,15 @@
1
+ require_relative "./base_executor"
2
+
3
+ module QuietQuality
4
+ module Executors
5
+ class SerialExecutor < BaseExecutor
6
+ def execute!
7
+ pipelines.each do |pipeline|
8
+ pipeline.outcome
9
+ pipeline.messages
10
+ end
11
+ pipelines.none?(&:failure?)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module QuietQuality
2
+ module Executors
3
+ Error = Class.new(::QuietQuality::Error)
4
+ end
5
+ end
6
+
7
+ glob = File.expand_path("../executors/*.rb", __FILE__)
8
+ Dir.glob(glob).sort.each { |f| require f }
9
+
10
+ module QuietQuality
11
+ module Executors
12
+ AVAILABLE = {
13
+ serial: SerialExecutor,
14
+ concurrent: ConcurrentExecutor
15
+ }.freeze
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ module QuietQuality
2
+ class Message
3
+ attr_accessor :annotated_line
4
+ attr_reader :path, :body, :start_line, :stop_line, :level, :rule
5
+
6
+ def self.load(hash)
7
+ new(**hash)
8
+ end
9
+
10
+ def initialize(**attrs)
11
+ @attrs = attrs.map { |k, v| [k.to_s, v] }.to_h
12
+ @path = @attrs.fetch("path")
13
+ @body = @attrs.fetch("body")
14
+ @start_line = @attrs.fetch("start_line")
15
+ @stop_line = @attrs.fetch("stop_line", @start_line)
16
+ @annotated_line = @attrs.fetch("annotated_line", nil)
17
+ @level = @attrs.fetch("level", nil)
18
+ @rule = @attrs.fetch("rule", nil)
19
+ end
20
+
21
+ def to_h
22
+ @attrs.map { |k, v| [k.to_s, v] }.to_h
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ module QuietQuality
2
+ class MessageFilter
3
+ def initialize(changed_files:)
4
+ @changed_files = changed_files
5
+ end
6
+
7
+ def relevant?(message)
8
+ return false unless changed_files.include?(message.path)
9
+
10
+ file = changed_files.file(message.path)
11
+ return true if file.entire?
12
+
13
+ relevant_lines?(message, file)
14
+ end
15
+
16
+ def filter(messages)
17
+ Messages.new(messages.select { |m| relevant?(m) })
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :changed_files
23
+
24
+ def relevant_lines?(message, file)
25
+ if message.stop_line == message.start_line
26
+ file.lines.include?(message.start_line)
27
+ else
28
+ message_range = (message.start_line..message.stop_line)
29
+ file.line_numbers.any? { |n| message_range.cover?(n) }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,54 @@
1
+ module QuietQuality
2
+ class Messages
3
+ include Enumerable
4
+
5
+ def self.load_data(data)
6
+ messages = data.map { |message_data| Message.new(**message_data) }
7
+ new(messages)
8
+ end
9
+
10
+ def self.load_json(text)
11
+ load_data(JSON.parse(text))
12
+ end
13
+
14
+ def self.load_yaml(text)
15
+ load_data(YAML.safe_load(text))
16
+ end
17
+
18
+ def initialize(messages)
19
+ @messages = messages
20
+ end
21
+
22
+ def to_hashes
23
+ messages.map(&:to_h)
24
+ end
25
+
26
+ def to_json(pretty: false)
27
+ pretty ? JSON.pretty_generate(to_hashes) : JSON.generate(to_hashes)
28
+ end
29
+
30
+ def to_yaml
31
+ to_hashes.to_yaml
32
+ end
33
+
34
+ def all
35
+ messages
36
+ end
37
+
38
+ def empty?
39
+ messages.length == 0
40
+ end
41
+
42
+ def each(&block)
43
+ if block
44
+ messages.each(&block)
45
+ else
46
+ to_enum(:each)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :messages
53
+ end
54
+ end
@@ -0,0 +1,32 @@
1
+ module QuietQuality
2
+ class ToolOptions
3
+ def initialize(tool, limit_targets: true, filter_messages: true)
4
+ @tool_name = tool.to_sym
5
+ @limit_targets = limit_targets
6
+ @filter_messages = filter_messages
7
+ end
8
+
9
+ attr_reader :tool_name
10
+ attr_writer :limit_targets, :filter_messages
11
+
12
+ def limit_targets?
13
+ @limit_targets
14
+ end
15
+
16
+ def filter_messages?
17
+ @filter_messages
18
+ end
19
+
20
+ def tool_namespace
21
+ Tools::AVAILABLE.fetch(tool_name)
22
+ end
23
+
24
+ def runner_class
25
+ tool_namespace::Runner
26
+ end
27
+
28
+ def parser_class
29
+ tool_namespace::Parser
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ module QuietQuality
2
+ module Tools
3
+ class Outcome
4
+ attr_reader :output, :logging, :tool
5
+
6
+ def initialize(tool:, output:, logging: nil, failure: false)
7
+ @tool = tool
8
+ @output = output
9
+ @logging = logging
10
+ @failure = failure
11
+ end
12
+
13
+ def failure?
14
+ @failure
15
+ end
16
+
17
+ def success?
18
+ !failure?
19
+ end
20
+
21
+ def ==(other)
22
+ tool == other.tool && output == other.output && logging == other.logging && failure? == other.failure?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Rspec
4
+ class Parser
5
+ def initialize(text)
6
+ @text = text
7
+ end
8
+
9
+ def messages
10
+ return @_messages if defined?(@_messages)
11
+ messages = failed_examples.map { |ex| message_for(ex) }
12
+ @_messages = Messages.new(messages)
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :text
18
+
19
+ def content
20
+ @_content ||= JSON.parse(text, symbolize_names: true)
21
+ end
22
+
23
+ def examples
24
+ @_examples ||= content.fetch(:examples)
25
+ end
26
+
27
+ def failed_examples
28
+ @_failed_examples ||= examples.select { |ex| ex[:status] == "failed" }
29
+ end
30
+
31
+ def reduced_path(path)
32
+ path.gsub(%r{^\./}, "")
33
+ end
34
+
35
+ def message_for(example)
36
+ path = reduced_path(example.fetch(:file_path))
37
+ body = example.dig(:exception, :message) || example.fetch(:description)
38
+ line = example.fetch(:line_number)
39
+ rule = example.dig(:exception, :class) || "Failed Example"
40
+ Message.new(path: path, body: body, start_line: line, rule: rule)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Rspec
4
+ class Runner
5
+ MAX_FILES = 100
6
+ NO_FILES_OUTPUT = '{"examples": [], "summary": {"failure_count": 0}}'
7
+
8
+ def initialize(changed_files: nil)
9
+ @changed_files = changed_files
10
+ end
11
+
12
+ def invoke!
13
+ @_outcome ||= skip_execution? ? skipped_outcome : performed_outcome
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :changed_files, :error_stream
19
+
20
+ def skip_execution?
21
+ changed_files && relevant_files.empty?
22
+ end
23
+
24
+ def relevant_files
25
+ return nil if changed_files.nil?
26
+ changed_files.paths.select { |path| path.end_with?("_spec.rb") }
27
+ end
28
+
29
+ def target_files
30
+ return [] if changed_files.nil?
31
+ return [] if relevant_files.length > MAX_FILES
32
+ relevant_files
33
+ end
34
+
35
+ def command
36
+ return nil if skip_execution?
37
+ ["rspec", "-f", "json"] + target_files.sort
38
+ end
39
+
40
+ def skipped_outcome
41
+ Outcome.new(tool: :rspec, output: NO_FILES_OUTPUT)
42
+ end
43
+
44
+ def performed_outcome
45
+ out, err, stat = Open3.capture3(*command)
46
+ if stat.success?
47
+ Outcome.new(tool: :rspec, output: out, logging: err)
48
+ elsif stat.exitstatus == 1
49
+ Outcome.new(tool: :rspec, output: out, logging: err, failure: true)
50
+ else
51
+ fail(ExecutionError, "Execution of rspec failed with #{stat.exitstatus}")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,11 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Rspec
4
+ ExecutionError = Class.new(Tools::Error)
5
+ ParsingError = Class.new(Tools::Error)
6
+ end
7
+ end
8
+ end
9
+
10
+ glob = File.expand_path("../rspec/*.rb", __FILE__)
11
+ Dir.glob(glob).sort.each { |f| require f }
@@ -0,0 +1,46 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Rubocop
4
+ class Parser
5
+ def initialize(text)
6
+ @text = text
7
+ end
8
+
9
+ def messages
10
+ return @_messages if defined?(@_messages)
11
+ messages = content
12
+ .fetch(:files)
13
+ .map { |f| messages_for_file(f) }
14
+ .flatten
15
+ @_messages = Messages.new(messages)
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :text
21
+
22
+ def content
23
+ @_content ||= JSON.parse(text, symbolize_names: true)
24
+ end
25
+
26
+ def messages_for_file(file_details)
27
+ path = file_details.fetch(:path)
28
+ file_details.fetch(:offenses).map do |offense|
29
+ message_for_offense(path, offense)
30
+ end
31
+ end
32
+
33
+ def message_for_offense(path, offense)
34
+ Message.new(
35
+ path: path,
36
+ body: offense.fetch(:message),
37
+ start_line: offense.dig(:location, :start_line),
38
+ stop_line: offense.dig(:location, :last_line),
39
+ level: offense.fetch(:severity, nil),
40
+ rule: offense.fetch(:cop_name, nil)
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,67 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Rubocop
4
+ class Runner
5
+ MAX_FILES = 100
6
+ NO_FILES_OUTPUT = '{"files": [], "summary": {"offense_count": 0}}'
7
+
8
+ def command_name
9
+ "rubocop"
10
+ end
11
+
12
+ # Supplying changed_files: nil means "run against all files".
13
+ def initialize(changed_files: nil)
14
+ @changed_files = changed_files
15
+ end
16
+
17
+ def invoke!
18
+ @_outcome ||= skip_execution? ? skipped_outcome : performed_outcome
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :changed_files
24
+
25
+ # If we were told that _no files changed_ (which is distinct from not being told that
26
+ # any files changed - a [] instead of a nil), then we shouldn't run rubocop at all.
27
+ def skip_execution?
28
+ changed_files && relevant_files.empty?
29
+ end
30
+
31
+ # Note: if target_files goes over MAX_FILES, it's _empty_ instead - that means that
32
+ # we run against the full repository instead of the specific files (rubocop's behavior
33
+ # when no target files are specified)
34
+ def command
35
+ return nil if skip_execution?
36
+ [command_name, "-f", "json"] + target_files.sort
37
+ end
38
+
39
+ def relevant_files
40
+ return nil if changed_files.nil?
41
+ changed_files.paths.select { |path| path.end_with?(".rb") }
42
+ end
43
+
44
+ def target_files
45
+ return [] if changed_files.nil?
46
+ return [] if relevant_files.length > MAX_FILES
47
+ relevant_files
48
+ end
49
+
50
+ def skipped_outcome
51
+ Outcome.new(tool: command_name.to_sym, output: NO_FILES_OUTPUT)
52
+ end
53
+
54
+ def performed_outcome
55
+ out, err, stat = Open3.capture3(*command)
56
+ if stat.success?
57
+ Outcome.new(tool: command_name.to_sym, output: out, logging: err)
58
+ elsif stat.exitstatus == 1
59
+ Outcome.new(tool: command_name.to_sym, output: out, logging: err, failure: true)
60
+ else
61
+ fail(ExecutionError, "Execution of #{command_name} failed with #{stat.exitstatus}")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,11 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Rubocop
4
+ ExecutionError = Class.new(Tools::Error)
5
+ ParsingError = Class.new(Tools::Error)
6
+ end
7
+ end
8
+ end
9
+
10
+ glob = File.expand_path("../rubocop/*.rb", __FILE__)
11
+ Dir.glob(glob).sort.each { |f| require f }
@@ -0,0 +1,8 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Standardrb
4
+ class Parser < Rubocop::Parser
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module QuietQuality
2
+ module Tools
3
+ module Standardrb
4
+ class Runner < Rubocop::Runner
5
+ def command_name
6
+ "standardrb"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "./rubocop"
2
+
3
+ module QuietQuality
4
+ module Tools
5
+ module Standardrb
6
+ ExecutionError = Class.new(Tools::Error)
7
+ ParsingError = Class.new(Tools::Error)
8
+ end
9
+ end
10
+ end
11
+
12
+ glob = File.expand_path("../standardrb/*.rb", __FILE__)
13
+ Dir.glob(glob).sort.each { |f| require f }
@@ -0,0 +1,21 @@
1
+ require "open3"
2
+
3
+ module QuietQuality
4
+ module Tools
5
+ Error = Class.new(::QuietQuality::Error)
6
+ end
7
+ end
8
+
9
+ glob = File.expand_path("../tools/*.rb", __FILE__)
10
+ Dir.glob(glob).sort.each { |f| require f }
11
+
12
+ # reopen the class after the tools have been loaded, so we can list them for reference elsewhere.
13
+ module QuietQuality
14
+ module Tools
15
+ AVAILABLE = {
16
+ rspec: Rspec,
17
+ rubocop: Rubocop,
18
+ standardrb: Standardrb
19
+ }
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module QuietQuality
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,117 @@
1
+ module QuietQuality
2
+ module VersionControlSystems
3
+ class Git
4
+ Error = Class.new(VersionControlSystems::Error)
5
+
6
+ attr_reader :git, :path
7
+
8
+ #
9
+ # Initializer
10
+ #
11
+ # @param [String] path Path to git repository
12
+ #
13
+ def initialize(path = ".")
14
+ @path = path
15
+ @git = ::Git.open(path)
16
+ end
17
+
18
+ # Retrieves the files changed in the given commit compared to the base. When no base is given,
19
+ # the default branch is used as the base. When no sha is given, the HEAD commit is used.
20
+ # Optionally, uncommitted changes can be included in the result, as well as untracked files.
21
+ #
22
+ # @param [String] base The base commit to compare against
23
+ # @param [String] sha The commit to compare
24
+ # @param [Boolean] include_uncommitted Whether to include uncommitted changes
25
+ # @param [Boolean] include_untracked Whether to include untracked files
26
+ #
27
+ # @return [Hash] A hash of file paths and the files changed in those files as a Set
28
+ def changed_files(base: nil, sha: "HEAD", include_uncommitted: true, include_untracked: false)
29
+ base_commit = comparison_base(sha: sha, comparison_branch: base || default_branch)
30
+ [
31
+ committed_changed_files(base_commit, sha),
32
+ include_uncommitted ? uncommitted_changed_files : nil,
33
+ include_untracked ? untracked_changed_files : nil
34
+ ].compact.reduce(&:merge)
35
+ end
36
+
37
+ #
38
+ # The default branch for the default remote for the local git repository
39
+ #
40
+ # @return [String] Branch name
41
+ def default_branch
42
+ self.class.default_branch(remote: git.remote.url)
43
+ end
44
+
45
+ #
46
+ # The default branch for the given remote
47
+ #
48
+ # @param [String] remote The remote repository url
49
+ # @return [String] Branch name
50
+ #
51
+ def self.default_branch(remote:)
52
+ ::Git.default_branch(remote)
53
+ end
54
+
55
+ #
56
+ # Determines the nearest common ancestor for the given `sha` compared to the `branch`.
57
+ #
58
+ # @param [String] sha The git SHA of the commit
59
+ # @param [String] comparison_branch The comparison branch
60
+ #
61
+ # @return [String] The nearest common ancestor (SHA)
62
+ #
63
+ def comparison_base(sha:, comparison_branch:)
64
+ git.merge_base(comparison_branch, sha).first.sha
65
+ end
66
+
67
+ private
68
+
69
+ def changed_lines_for(diff)
70
+ GitDiffParser.parse(diff).flat_map do |parsed_diff|
71
+ parsed_diff.changed_line_numbers.to_set
72
+ end
73
+ end
74
+
75
+ def committed_changed_files(base, sha)
76
+ ChangedFiles.new(committed_changes(base, sha))
77
+ end
78
+
79
+ def committed_changes(base, sha)
80
+ patch = git.diff(base, sha).patch
81
+ GitDiffParser.parse(patch).map { to_changed_file(_1) }
82
+ end
83
+
84
+ def to_changed_file(patch_file)
85
+ ChangedFile.new(path: patch_file.file, lines: patch_file.changed_line_numbers.to_set)
86
+ end
87
+
88
+ def uncommitted_changed_files
89
+ ChangedFiles.new(uncommitted_changes)
90
+ end
91
+
92
+ def uncommitted_changes
93
+ patch = git.diff.patch
94
+ GitDiffParser.parse(patch).map { to_changed_file(_1) }
95
+ end
96
+
97
+ def untracked_changed_files
98
+ ChangedFiles.new(untracked_changes)
99
+ end
100
+
101
+ def untracked_changes
102
+ untracked_paths.map do |file_path|
103
+ ChangedFile.new(path: file_path, lines: :all)
104
+ end
105
+ end
106
+
107
+ def untracked_paths
108
+ out, err, stat = Open3.capture3("git", "-C", path, "ls-files", "--others", "--exclude-standard")
109
+ unless stat.success?
110
+ warn err
111
+ fail(Error, "git ls-files failed")
112
+ end
113
+ out.split
114
+ end
115
+ end
116
+ end
117
+ end