cleo_quality_review 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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/cleo_quality_review.gemspec +31 -0
  3. data/config/default.yml +7 -0
  4. data/exe/check_quality +20 -0
  5. data/lib/cleo_quality_review/changes_diff.rb +67 -0
  6. data/lib/cleo_quality_review/checks/debride.rb +65 -0
  7. data/lib/cleo_quality_review/checks/fasterer.rb +35 -0
  8. data/lib/cleo_quality_review/checks/flog.rb +35 -0
  9. data/lib/cleo_quality_review/checks/quality_check.rb +143 -0
  10. data/lib/cleo_quality_review/checks/reek.rb +53 -0
  11. data/lib/cleo_quality_review/checks/registry.rb +72 -0
  12. data/lib/cleo_quality_review/checks.rb +38 -0
  13. data/lib/cleo_quality_review/cli.rb +105 -0
  14. data/lib/cleo_quality_review/command_result.rb +21 -0
  15. data/lib/cleo_quality_review/command_runner.rb +27 -0
  16. data/lib/cleo_quality_review/configuration.rb +193 -0
  17. data/lib/cleo_quality_review/diff_map.rb +95 -0
  18. data/lib/cleo_quality_review/formatter.rb +58 -0
  19. data/lib/cleo_quality_review/github_review_builder.rb +140 -0
  20. data/lib/cleo_quality_review/github_review_publisher.rb +150 -0
  21. data/lib/cleo_quality_review/llm_client.rb +59 -0
  22. data/lib/cleo_quality_review/llm_config.rb +40 -0
  23. data/lib/cleo_quality_review/llm_errors.rb +19 -0
  24. data/lib/cleo_quality_review/llm_logger.rb +66 -0
  25. data/lib/cleo_quality_review/llm_providers/open_ai.rb +188 -0
  26. data/lib/cleo_quality_review/llm_providers/open_ai_config.rb +83 -0
  27. data/lib/cleo_quality_review/llm_providers/registry.rb +61 -0
  28. data/lib/cleo_quality_review/llm_providers/stub.rb +107 -0
  29. data/lib/cleo_quality_review/llm_providers.rb +44 -0
  30. data/lib/cleo_quality_review/options.rb +171 -0
  31. data/lib/cleo_quality_review/prompt_builder.rb +95 -0
  32. data/lib/cleo_quality_review/prompt_loader.rb +49 -0
  33. data/lib/cleo_quality_review/result.rb +58 -0
  34. data/lib/cleo_quality_review/run.rb +78 -0
  35. data/lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb +97 -0
  36. data/lib/cleo_quality_review/run_artifacts.rb +146 -0
  37. data/lib/cleo_quality_review/runner.rb +158 -0
  38. data/lib/cleo_quality_review/target_resolver.rb +127 -0
  39. data/lib/cleo_quality_review/version.rb +7 -0
  40. data/lib/cleo_quality_review.rb +23 -0
  41. data/prompts/agent.md +53 -0
  42. data/prompts/github.md +29 -0
  43. data/prompts/human.md +23 -0
  44. data/prompts/pr_review.md +62 -0
  45. metadata +141 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 63b36db131462e8bedbfe7e7f1d42084a4f600464a6a6b881231a9254d0f4e5b
4
+ data.tar.gz: 5722693b1abeaee01930f71c0e3d5d57333677c2d62bd3f4eaffc613094e3e7b
5
+ SHA512:
6
+ metadata.gz: 31cf5bfcac10c05eb3f1297b132e60b1b730c951c382351879750f51362419df464c103f368e3571dfc6545070f37281827e477cf80af07420d08c8a3a7e4e37
7
+ data.tar.gz: 956cba96beddc95e78ec88b27d0c7e2b25f0b94dd997f8a5ee8b137ab2a34ee7be42e75fb18300fdd5abfc5260978b428d280e6359f730c0cad8b406c58f40cc
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/cleo_quality_review/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cleo_quality_review"
7
+ spec.version = CleoQualityReview::VERSION
8
+ spec.authors = ["Gavin Morrice"]
9
+ spec.email = ["gavin@gavinmorrice.com"]
10
+
11
+ spec.summary = "Local Cleo code quality checks"
12
+ spec.description = "Runs local quality checks and summarizes their output for humans, agents, or GitHub."
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.2.0"
15
+
16
+ spec.metadata["rubygems_mfa_required"] = "true"
17
+
18
+ spec.files =
19
+ Dir.glob("{#{File.basename(__FILE__)},config/**/*,exe/**/*,lib/**/*,prompts/**/*}", File::FNM_DOTMATCH).select do |path|
20
+ File.file?(path) && !File.symlink?(path)
21
+ end
22
+
23
+ spec.bindir = "exe"
24
+ spec.executables = ["check_quality"]
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "debride"
28
+ spec.add_dependency "fasterer"
29
+ spec.add_dependency "flog"
30
+ spec.add_dependency "reek"
31
+ end
@@ -0,0 +1,7 @@
1
+ AllTools:
2
+ Include:
3
+ - "**/*.rb"
4
+ Exclude:
5
+ - "tmp/**/*"
6
+ - "vendor/**/*"
7
+ - "node_modules/**/*"
data/exe/check_quality ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ $LOAD_PATH << File.expand_path('../lib', __dir__)
4
+
5
+ require "cleo_quality_review/cli"
6
+
7
+ ##
8
+ # Load development environment variables
9
+ # This should not be used when packaging in production
10
+ if ENV.fetch('CLEO_QUALITY_REVIEW_DEV', 'false') == 'true'
11
+ begin
12
+ require 'dotenv'
13
+ local_dotenv = File.expand_path("../.env.development.local", __dir__)
14
+ Dotenv.load(local_dotenv)
15
+ rescue LoadError
16
+ puts "No local .env.development found. Skip."
17
+ end
18
+ end
19
+
20
+ exit CleoQualityReview::CLI.new(ARGV).run
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "target_resolver"
6
+
7
+ module CleoQualityReview
8
+ ##
9
+ # Captures the git diff used for a quality review run
10
+ class ChangesDiff
11
+ ##
12
+ # @param [Array<String>] target_files files included in the review
13
+ # @param [CommandRunner] command_runner for executing git commands
14
+ def initialize(target_files:, command_runner:)
15
+ @target_files = target_files
16
+ @command_runner = command_runner
17
+ end
18
+
19
+ ##
20
+ # @return [String] combined tracked and untracked diff content
21
+ def to_s
22
+ @to_s ||= [tracked_changes_diff, untracked_changes_diff].reject(&:empty?).join("\n")
23
+ end
24
+
25
+ ##
26
+ # @return [String] deterministic review identifier for this diff
27
+ def review_id
28
+ Digest::SHA256.hexdigest(to_s)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :command_runner, :target_files
34
+
35
+ def tracked_changes_diff
36
+ command = ["git", "diff", diff_base]
37
+ command.concat(["--", *target_files]) unless target_files.empty?
38
+
39
+ command_runner.run(*command).stdout
40
+ end
41
+
42
+ def untracked_changes_diff
43
+ untracked_target_files.map do |filepath|
44
+ command_runner.run("git", "diff", "--no-index", "--", "/dev/null", filepath).stdout
45
+ end.reject(&:empty?).join("\n")
46
+ end
47
+
48
+ def untracked_target_files
49
+ command = ["git", "ls-files", "--others", "--exclude-standard"]
50
+ empty_targets = target_files.empty?
51
+ command.concat(["--", *target_files]) unless empty_targets
52
+
53
+ command_runner.run(*command).stdout.lines.map(&:strip).select do |path|
54
+ empty_targets || target_files.include?(path)
55
+ end
56
+ end
57
+
58
+ def diff_base
59
+ @diff_base ||= begin
60
+ result = command_runner.run("git", "merge-base", TargetResolver::BASE_REF, "HEAD")
61
+ base = result.stdout.strip
62
+
63
+ result.success? && !base.empty? ? base : TargetResolver::BASE_REF
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "quality_check"
6
+
7
+ module CleoQualityReview
8
+ module Checks
9
+ ##
10
+ # Quality check implementation for Debride unused-code analyzer
11
+ class Debride < QualityCheck
12
+ self.check_name = "dead_code"
13
+ self.tool_name = "debride"
14
+ self.output_extension = "json"
15
+
16
+ private
17
+
18
+ def command(files)
19
+ [ruby_executable, gem_executable("debride", "debride"), "--json", "--rails", *files]
20
+ end
21
+
22
+ def parse(stdout, stderr)
23
+ findings = missing_methods(stdout).flat_map do |class_name, methods|
24
+ results_for_class(class_name, methods)
25
+ end
26
+ return findings unless findings.empty? && stderr.to_s.strip != ""
27
+
28
+ [result(check: "Execution error", message: stderr, filepath: nil)]
29
+ end
30
+
31
+ def missing_methods(stdout)
32
+ parsed = JSON.parse(stdout.to_s)
33
+ missing = parsed.fetch("missing", {})
34
+ return {} unless missing.is_a?(Hash)
35
+
36
+ missing
37
+ rescue JSON::ParserError
38
+ {}
39
+ end
40
+
41
+ def results_for_class(class_name, methods)
42
+ Array(methods).map { |entry| method_to_result(class_name, entry) }
43
+ end
44
+
45
+ def method_to_result(class_name, entry)
46
+ method_name, location = Array(entry)
47
+ filepath, line = parse_location(location)
48
+
49
+ result(
50
+ check: "PotentialDeadMethod",
51
+ message: "#{class_name}##{method_name} might not be called",
52
+ filepath: filepath,
53
+ line: line,
54
+ )
55
+ end
56
+
57
+ def parse_location(location)
58
+ match = location.to_s.match(/\A(?<filepath>.*):(?<line>\d+)(?:-\d+)?\z/)
59
+ return [nil, nil] unless match
60
+
61
+ match.values_at(:filepath, :line)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "quality_check"
4
+
5
+ module CleoQualityReview
6
+ module Checks
7
+ ##
8
+ # Quality check implementation for Fasterer performance analyzer
9
+ class Fasterer < QualityCheck
10
+ self.check_name = "fasterer"
11
+ self.tool_name = "fasterer"
12
+
13
+ private
14
+
15
+ def command(files)
16
+ [ruby_executable, gem_executable("fasterer", "fasterer"), *files]
17
+ end
18
+
19
+ def parse(stdout, stderr)
20
+ findings = stdout.to_s.lines.filter_map { |line| parse_line(line) }
21
+ return findings unless findings.empty? && stderr.to_s.strip != ""
22
+
23
+ [result(check: "Execution error", message: stderr, filepath: nil)]
24
+ end
25
+
26
+ def parse_line(line)
27
+ match = line.to_s.gsub(/\e\[[\d;]*m/, "").match(/^(?<filepath>.+?):(?<line>\d+):?\s+(?<message>.+)$/)
28
+ return unless match
29
+
30
+ filepath, line_number, message = match.values_at(:filepath, :line, :message)
31
+ result(check: "Performance", message: message, filepath: filepath, line: line_number)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "quality_check"
4
+
5
+ module CleoQualityReview
6
+ module Checks
7
+ ##
8
+ # Quality check implementation for Flog complexity analyzer
9
+ class Flog < QualityCheck
10
+ self.check_name = "flog"
11
+ self.tool_name = "flog"
12
+
13
+ private
14
+
15
+ def command(files)
16
+ [ruby_executable, gem_executable("flog", "flog"), "--all", "--methods", *files]
17
+ end
18
+
19
+ def parse(stdout, stderr)
20
+ findings = stdout.to_s.lines.filter_map { |line| parse_line(line) }
21
+ return findings unless findings.empty? && stderr.to_s.strip != ""
22
+
23
+ [result(check: "Execution error", message: stderr, filepath: nil)]
24
+ end
25
+
26
+ def parse_line(line)
27
+ match = line.match(/^\s*(?<score>\d+(?:\.\d+)?):\s+(?<subject>.+?)\s+(?<filepath>[^:\s]+):(?<line>\d+)/)
28
+ return unless match
29
+
30
+ score, subject, filepath, line_number = match.values_at(:score, :subject, :filepath, :line)
31
+ result(check: "Complexity", message: "#{score}: #{subject}", filepath: filepath, line: line_number)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ require_relative "../result"
6
+
7
+ module CleoQualityReview
8
+ module Checks
9
+ ##
10
+ # Base class for quality check implementations
11
+ class QualityCheck
12
+ class << self
13
+ ##
14
+ # @!attribute [rw] check_name
15
+ # @return [String] identifier for this check
16
+ attr_accessor :check_name
17
+
18
+ # @!attribute [rw] tool_name
19
+ # @return [String] tool name for result attribution
20
+ attr_accessor :tool_name
21
+
22
+ # @!attribute [rw] tool_type
23
+ # @return [String] category for this tool's findings
24
+ attr_accessor :tool_type
25
+
26
+ # @!attribute [rw] output_extension
27
+ # @return [String] file extension for raw output
28
+ attr_accessor :output_extension
29
+
30
+ ##
31
+ # Set default output extension for subclasses
32
+ # @param [Class] subclass the inheriting class
33
+ # @return [void]
34
+ def inherited(subclass)
35
+ super
36
+ subclass.output_extension = "txt"
37
+ end
38
+
39
+ def output_metadata
40
+ {
41
+ check_name: check_name,
42
+ tool_name: tool_name,
43
+ tool_type: tool_type,
44
+ extension: output_extension,
45
+ }
46
+ end
47
+ end
48
+
49
+ ##
50
+ # @param [CommandRunner] command_runner for executing shell commands
51
+ # @param [Integer] timestamp epoch milliseconds for the run
52
+ def initialize(command_runner:, timestamp:)
53
+ @command_runner = command_runner
54
+ @timestamp = timestamp
55
+ end
56
+
57
+ ##
58
+ # Run the quality check on the given files
59
+ # @param [Array<String>] files file paths to analyze
60
+ # @return [CheckOutput]
61
+ def run(files)
62
+ return empty_output if files.empty?
63
+
64
+ command_result = command_runner.run(*command(files))
65
+ build_output(
66
+ raw_output: raw_output(command_result),
67
+ results: parse(command_result.stdout, parseable_stderr(command_result)),
68
+ )
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :command_runner, :timestamp
74
+
75
+ def check_metadata
76
+ @check_metadata ||= self.class.output_metadata
77
+ end
78
+
79
+ def empty_output
80
+ build_output(raw_output: "", results: [])
81
+ end
82
+
83
+ def build_output(raw_output:, results:)
84
+ CheckOutput.new(
85
+ **check_metadata,
86
+ raw_output: raw_output,
87
+ results: results,
88
+ )
89
+ end
90
+
91
+ def ruby_executable
92
+ RbConfig.ruby
93
+ end
94
+
95
+ def gem_executable(gem_name, executable_name)
96
+ Gem.bin_path(gem_name, executable_name)
97
+ end
98
+
99
+ def raw_output(command_result)
100
+ stdout = command_result.stdout
101
+ return stdout if command_result.success?
102
+
103
+ stderr = command_result.stderr
104
+ return stdout if stderr.empty?
105
+
106
+ [stdout, stderr].reject(&:empty?).join("\n")
107
+ end
108
+
109
+ def parseable_stderr(command_result)
110
+ command_result.success? ? "" : command_result.stderr
111
+ end
112
+
113
+ def result(attributes)
114
+ check, message, filepath, line = attributes.values_at(:check, :message, :filepath, :line)
115
+ CleoQualityReview::Result.new(
116
+ **check_metadata.slice(:tool_name, :tool_type),
117
+ check: check,
118
+ timestamp: timestamp,
119
+ result: message,
120
+ filepath: filepath,
121
+ line: line&.to_i,
122
+ )
123
+ end
124
+ end
125
+
126
+ ##
127
+ # Value object containing check output and parsed results
128
+ #
129
+ # @!attribute [r] check_name
130
+ # @return [String] identifier for the check
131
+ # @!attribute [r] tool_name
132
+ # @return [String] name of the concrete tool
133
+ # @!attribute [r] tool_type
134
+ # @return [String] category for this tool's findings
135
+ # @!attribute [r] extension
136
+ # @return [String] file extension for the raw output
137
+ # @!attribute [r] raw_output
138
+ # @return [String] raw tool output
139
+ # @!attribute [r] results
140
+ # @return [Array<Result>] parsed findings
141
+ CheckOutput = Struct.new(:check_name, :tool_name, :tool_type, :extension, :raw_output, :results, keyword_init: true)
142
+ end
143
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "quality_check"
6
+
7
+ module CleoQualityReview
8
+ module Checks
9
+ ##
10
+ # Quality check implementation for Reek code smell detector
11
+ class Reek < QualityCheck
12
+ self.check_name = "reek"
13
+ self.tool_name = "reek"
14
+ self.output_extension = "json"
15
+
16
+ private
17
+
18
+ def command(files)
19
+ [ruby_executable, gem_executable("reek", "reek"), "--format", "json", *files]
20
+ end
21
+
22
+ def parse(stdout, stderr)
23
+ smells = parse_json(stdout)
24
+ return stderr_result(stderr) if smells.empty? && stderr.to_s.strip != ""
25
+
26
+ smells.map { |smell| smell_to_result(smell) }
27
+ end
28
+
29
+ def smell_to_result(smell)
30
+ result(
31
+ check: smell.fetch("smell_type", "Reek"),
32
+ message: smell_message(smell),
33
+ filepath: smell.fetch("source", nil),
34
+ line: Array(smell["lines"]).first,
35
+ )
36
+ end
37
+
38
+ def parse_json(stdout)
39
+ JSON.parse(stdout.to_s)
40
+ rescue JSON::ParserError
41
+ []
42
+ end
43
+
44
+ def smell_message(smell)
45
+ [smell["context"], smell["message"]].compact.join(": ")
46
+ end
47
+
48
+ def stderr_result(stderr)
49
+ [result(check: "Execution error", message: stderr, filepath: nil)]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ module Checks
5
+ ##
6
+ # Registry for available quality check implementations
7
+ class Registry
8
+ class UnknownCheckError < CleoQualityReview::Error
9
+ def initialize(name)
10
+ super("Unknown check name: #{name}")
11
+ end
12
+ end
13
+
14
+ require 'delegate'
15
+ class CheckName < DelegateClass(String)
16
+ def initialize(name)
17
+ super(name.to_s.downcase)
18
+ end
19
+
20
+ end
21
+
22
+ Registration = Data.define(:check_name, :klass, :tool_type)
23
+ class << self
24
+ ##
25
+ # Register a quality check implementation
26
+ # @param [String] name check identifier
27
+ # @param [Class] klass class name under CleoQualityReview
28
+ # @param [Symbol, String] tool_type category of tool findings
29
+ # @return [void]
30
+ def register(name, klass, tool_type:)
31
+ name = CheckName.new(name).to_s
32
+ registration = Registration.new(check_name: name.to_s, klass: klass, tool_type: tool_type.to_s)
33
+ registrations[name] = registration
34
+ nil
35
+ end
36
+
37
+ ##
38
+ # Resolve check names to check classes
39
+ # @param [Array<String>] names check names to resolve
40
+ # @return [Array<Class>] resolved check classes
41
+ # @raise [ArgumentError] if an unknown check name is provided
42
+ def resolve(names)
43
+ names_to_resolve(names).map { |name| resolve_name(name) }
44
+ end
45
+
46
+ def registered?(tool_name)
47
+ registrations.key?(CheckName.new(tool_name))
48
+ end
49
+
50
+ private
51
+
52
+ def registrations
53
+ @registrations ||= {}
54
+ end
55
+
56
+ def names_to_resolve(names)
57
+ names = ['all'] if names.empty?
58
+
59
+ names.include?('all') ? registrations.keys : names.map(&:to_s)
60
+ end
61
+
62
+ def resolve_name(name)
63
+ name = CheckName.new(name).to_s
64
+ registration = registrations[name]
65
+ raise UnknownCheckError.new(name) unless registration
66
+
67
+ registration.klass
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ ##
5
+ # Namespace for bundled quality check implementations.
6
+ module Checks
7
+ require_relative "checks/registry"
8
+ require_relative "checks/quality_check"
9
+ require_relative "checks/reek"
10
+ require_relative "checks/flog"
11
+ require_relative "checks/fasterer"
12
+ require_relative "checks/debride"
13
+
14
+ class << self
15
+ ##
16
+ # Register a new check for use
17
+ # @param [String] tool_name
18
+ # @param [Class] tool_class
19
+ # @param [String, Symbol] tool_type
20
+ # @return [nil]
21
+ def register(tool_name, tool_class, tool_type: )
22
+ Registry.register(tool_name.to_s, tool_class, tool_type: tool_type.to_s)
23
+ end
24
+
25
+ def resolve(tool_names)
26
+ Registry.resolve(Array(tool_names))
27
+ end
28
+ ##
29
+ # Has a tool with the given name been registered?
30
+ #
31
+ # @param [String] tool_name
32
+ # @return [Boolean]
33
+ def registered?(tool_name)
34
+ Registry.registered?(tool_name)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../cleo_quality_review"
6
+ require_relative "command_runner"
7
+ require_relative "formatter"
8
+ require_relative "github_review_publisher"
9
+ require_relative "options"
10
+ require_relative "runner"
11
+ require_relative "run_artifacts"
12
+
13
+ module CleoQualityReview
14
+ ##
15
+ # Command-line interface entry point
16
+ class CLI
17
+ SUBCOMMANDS = {
18
+ "analyze" => :run_analyze,
19
+ "render" => :run_render,
20
+ "publish-pr-review" => :run_publish_pr_review,
21
+ }.freeze
22
+
23
+ ##
24
+ # @param [Array<String>] argv command-line arguments
25
+ # @param [IO] stdout standard output stream
26
+ # @param [IO] stderr standard error stream
27
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
28
+ @argv = argv
29
+ @stdout = stdout
30
+ @stderr = stderr
31
+ end
32
+
33
+ ##
34
+ # Execute the CLI
35
+ # @return [Integer] exit code (0 for success, 1 for error)
36
+ def run
37
+ dispatch_command
38
+ rescue Error, OptionParser::ParseError, ArgumentError => error
39
+ stderr.puts("check_quality: #{error.message}")
40
+ 1
41
+ end
42
+
43
+ private
44
+
45
+ def dispatch_command
46
+ @command_runner = CommandRunner.new
47
+ command = argv.first
48
+ subcommand = SUBCOMMANDS[command]
49
+
50
+ if subcommand
51
+ run_subcommand(subcommand, argv.drop(1))
52
+ else
53
+ run_one_shot(argv)
54
+ end
55
+ end
56
+
57
+ attr_reader :argv, :stdout, :stderr, :command_runner
58
+
59
+ def run_one_shot(arguments)
60
+ options = Options.parse(arguments)
61
+ run = Runner.new(options: options, command_runner: command_runner).run
62
+ output = Formatter.new(run: run, command_runner: command_runner).format
63
+ print_output(output)
64
+ 0
65
+ end
66
+
67
+ def run_subcommand(subcommand, arguments)
68
+ send(subcommand, arguments)
69
+ end
70
+
71
+ def run_analyze(arguments)
72
+ options = Options.parse(arguments)
73
+ run = Runner.new(options: options, command_runner: command_runner).run
74
+ stdout.puts(run.review_id)
75
+ 0
76
+ end
77
+
78
+ def run_render(arguments)
79
+ options = Options.parse(arguments)
80
+ run = RunArtifacts.load(review_id: options.validated_review_id).to_run(**options.run_loading_params)
81
+ output = Formatter.new(run: run, command_runner: command_runner).format
82
+ print_output(output)
83
+ 0
84
+ end
85
+
86
+ def run_publish_pr_review(arguments)
87
+ options = Options.parse(arguments)
88
+ run = RunArtifacts.load(review_id: options.validated_review_id).to_run(**options.run_loading_params)
89
+ output = GitHubReviewPublisher.new(run: run, rendered_review: rendered_pr_review(options, run)).publish
90
+ print_output(output)
91
+ 0
92
+ end
93
+
94
+ def print_output(output)
95
+ stdout.puts(output) unless output.empty?
96
+ end
97
+
98
+ def rendered_pr_review(options, run)
99
+ path = options.review_file || File.join(run.run_directory, "pr_review.json")
100
+ raise OptionParser::MissingArgument, "--review-file is required or #{path} must exist" unless File.file?(path)
101
+
102
+ File.read(path)
103
+ end
104
+ end
105
+ end