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
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_config"
4
+ require_relative "llm_logger"
5
+ require_relative "llm_providers"
6
+
7
+ module CleoQualityReview
8
+ ##
9
+ # Client for generating LLM reviews using configured provider
10
+ class LlmClient
11
+ ##
12
+ # @param [LlmConfig] config
13
+ # @param [Boolean] log whether to log queries and responses
14
+ def initialize(config: LlmConfig.new, log: false)
15
+ @config = config
16
+ @logger = LlmLogger.new(provider_name: config.provider, enabled: log)
17
+ provider.validate_config(config)
18
+ end
19
+
20
+ ##
21
+ # Generate a review from the given prompt
22
+ # @param [String] prompt
23
+ # @return [String] the generated review
24
+ def generate_review(prompt)
25
+ generate_with_logging(prompt)
26
+ rescue StandardError => e
27
+ log_error(prompt, e)
28
+ raise
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :config, :logger
34
+
35
+ def generate_with_logging(prompt)
36
+ provider_client.generate_review(prompt).tap { |response| log_success(prompt, response) }
37
+ end
38
+
39
+ def log_success(prompt, response)
40
+ logger.log(query: prompt, response: response)
41
+ end
42
+
43
+ def log_error(prompt, error)
44
+ logger.log(query: prompt, response: format_error(error))
45
+ end
46
+
47
+ def format_error(error)
48
+ "ERROR: #{error.class}: #{error.message}"
49
+ end
50
+
51
+ def provider_client
52
+ provider.build_client(config: config)
53
+ end
54
+
55
+ def provider
56
+ @provider ||= LlmProviders.fetch(config.provider)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_providers/open_ai_config"
4
+
5
+ module CleoQualityReview
6
+ ##
7
+ # Configuration for LLM provider
8
+ class LlmConfig
9
+ PROVIDER = "openai"
10
+
11
+ ##
12
+ # @param [Hash{String => String}] env environment variables
13
+ def initialize(env: ENV)
14
+ @env = env
15
+ end
16
+
17
+ ##
18
+ # @return [String] the provider name
19
+ def provider
20
+ PROVIDER
21
+ end
22
+
23
+ ##
24
+ # @return [LlmProviders::OpenAi::Config]
25
+ def open_ai_config
26
+ @open_ai_config ||= LlmProviders::OpenAi::Config.new(env: env)
27
+ end
28
+
29
+ ##
30
+ # @return [LlmProviders::Stub::Config]
31
+ def stub_config
32
+ require_relative "llm_providers/stub"
33
+ @stub_config ||= LlmProviders::Stub::Config.new(env: env)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :env
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ ##
5
+ # Base error class for CleoQualityReview
6
+ class Error < StandardError; end
7
+
8
+ ##
9
+ # Raised when required LLM configuration is missing
10
+ class MissingLlmConfigurationError < Error; end
11
+
12
+ ##
13
+ # Raised when an unsupported LLM provider is requested
14
+ class UnsupportedLlmProviderError < Error; end
15
+
16
+ ##
17
+ # Base class for LLM provider-specific errors
18
+ class LlmProviderError < Error; end
19
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "logger"
5
+
6
+ module CleoQualityReview
7
+ ##
8
+ # Logger for LLM queries and responses
9
+ class LlmLogger
10
+ LOG_DIR = "log"
11
+
12
+ ##
13
+ # @param [String] provider_name name of the LLM provider
14
+ # @param [Boolean] enabled whether logging is enabled
15
+ def initialize(provider_name:, enabled: false)
16
+ @provider_name = provider_name
17
+ @enabled = enabled
18
+ @logger = nil
19
+ end
20
+
21
+ ##
22
+ # Log a query and response
23
+ # @param [String] query the prompt sent to the LLM
24
+ # @param [String] response the LLM response
25
+ # @return [void]
26
+ def log(query:, response:)
27
+ return unless enabled
28
+
29
+ logger.info(format_entry(query: query, response: response))
30
+ end
31
+
32
+ ##
33
+ # @return [String] path to the log file
34
+ def log_path
35
+ File.join(LOG_DIR, "#{provider_name}.log")
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :provider_name, :enabled
41
+
42
+ def logger
43
+ @logger ||= build_logger
44
+ end
45
+
46
+ def build_logger
47
+ FileUtils.mkdir_p(LOG_DIR)
48
+ Logger.new(log_path, formatter: proc { |_, _, _, message| "#{message}\n" })
49
+ end
50
+
51
+ def format_entry(query:, response:)
52
+ timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
53
+ <<~ENTRY
54
+ ================================================================================
55
+ Timestamp: #{timestamp}
56
+ ================================================================================
57
+
58
+ --- QUERY ---
59
+ #{query}
60
+
61
+ --- RESPONSE ---
62
+ #{response}
63
+ ENTRY
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "../llm_errors"
8
+ require_relative "open_ai_config"
9
+
10
+ module CleoQualityReview
11
+ module LlmProviders
12
+ ##
13
+ # OpenAI provider implementation and support classes.
14
+ module OpenAi
15
+ ##
16
+ # Value object representing an HTTP response from OpenAI.
17
+ #
18
+ # @!attribute [r] status_code
19
+ # @return [Integer] HTTP status code
20
+ # @!attribute [r] body
21
+ # @return [String] response body
22
+ HttpResponse = Struct.new(:status_code, :body, keyword_init: true) do
23
+ ##
24
+ # Check if the response indicates success.
25
+ # @return [Boolean]
26
+ def success?
27
+ (200..299).cover?(status_code.to_i)
28
+ end
29
+ end
30
+
31
+ ##
32
+ # Value object for a JSON POST request to OpenAI.
33
+ HttpRequest = Struct.new(:uri, :headers, :body, :timeout_seconds, keyword_init: true) do
34
+ ##
35
+ # Execute the HTTP request.
36
+ # @param [Net::HTTP::Post] http_request prepared request
37
+ # @yield [Net::HTTP] yields configured HTTP connection
38
+ # @return [Object] result of the block
39
+ def execute(http_request)
40
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
41
+ http.open_timeout = timeout_seconds
42
+ http.read_timeout = timeout_seconds
43
+ http.write_timeout = timeout_seconds
44
+ http.request(http_request)
45
+ end
46
+ end
47
+ end
48
+
49
+ ##
50
+ # HTTP transport layer for OpenAI API requests.
51
+ class HttpTransport
52
+ ##
53
+ # Send a POST request with JSON body.
54
+ # @param [HttpRequest] request JSON POST request options
55
+ # @return [HttpResponse]
56
+ def post_json(request)
57
+ http_request = build_request(request)
58
+ response = perform_request(request, http_request)
59
+
60
+ HttpResponse.new(status_code: response.code.to_i, body: response.body.to_s)
61
+ end
62
+
63
+ private
64
+
65
+ def build_request(request)
66
+ http_request = Net::HTTP::Post.new(request.uri)
67
+ request.headers.each { |key, value| http_request[key] = value }
68
+ http_request.body = JSON.generate(request.body)
69
+ http_request
70
+ end
71
+
72
+ def perform_request(request, http_request)
73
+ request.execute(http_request)
74
+ end
75
+ end
76
+
77
+ ##
78
+ # Client for the OpenAI Responses API.
79
+ class Client
80
+ RESPONSES_API_URL = URI("https://api.openai.com/v1/responses")
81
+
82
+ ##
83
+ # @param [Config] config OpenAI configuration
84
+ # @param [HttpTransport] http_transport transport layer for HTTP requests
85
+ def initialize(config:, http_transport: HttpTransport.new)
86
+ @config = config
87
+ @http_transport = http_transport
88
+ end
89
+
90
+ ##
91
+ # Generate a review using the OpenAI Responses API.
92
+ # @param [String] prompt the prompt to send
93
+ # @return [String] generated review text
94
+ # @raise [ApiError] if the API request fails
95
+ def generate_review(prompt)
96
+ response = execute_request(prompt)
97
+ parse_response(response)
98
+ end
99
+
100
+ private
101
+
102
+ attr_reader :config, :http_transport
103
+
104
+ def execute_request(prompt)
105
+ timeout_seconds = config.timeout_seconds
106
+ http_transport.post_json(build_request(prompt, timeout_seconds))
107
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
108
+ raise ApiError, timeout_error_message(timeout_seconds, e)
109
+ end
110
+
111
+ def parse_response(response)
112
+ raise ApiError, api_error_message(response) unless response.success?
113
+
114
+ extract_text(JSON.parse(response.body))
115
+ rescue JSON::ParserError => e
116
+ raise ApiError, "OpenAI Responses API returned invalid JSON: #{e.message}"
117
+ end
118
+
119
+ def build_request(prompt, timeout_seconds)
120
+ HttpRequest.new(
121
+ uri: RESPONSES_API_URL,
122
+ headers: headers,
123
+ body: { model: config.model, input: prompt },
124
+ timeout_seconds: timeout_seconds,
125
+ )
126
+ end
127
+
128
+ def timeout_error_message(timeout_seconds, error)
129
+ "OpenAI Responses API request timed out after #{timeout_seconds} seconds: #{error.class}: #{error.message}"
130
+ end
131
+
132
+ def headers
133
+ {
134
+ "Authorization" => "Bearer #{config.api_key}",
135
+ "Content-Type" => "application/json",
136
+ }
137
+ end
138
+
139
+ def extract_text(response)
140
+ output_text = response["output_text"]
141
+ return output_text if output_text.to_s.strip != ""
142
+
143
+ text = extract_content_text(response)
144
+ return text unless text.empty?
145
+
146
+ JSON.pretty_generate(response)
147
+ end
148
+
149
+ def extract_content_text(response)
150
+ Array(response["output"]).flat_map { |item| extract_item_texts(item) }.join("\n")
151
+ end
152
+
153
+ def extract_item_texts(item)
154
+ Array(item["content"]).filter_map { |content| content["text"] }
155
+ end
156
+
157
+ def api_error_message(response)
158
+ "OpenAI Responses API request failed with status #{response.status_code}: #{response.body}"
159
+ end
160
+ end
161
+
162
+ ##
163
+ # OpenAI provider adapter for LlmClient.
164
+ class Provider
165
+ ##
166
+ # Validate that the config has required OpenAI settings.
167
+ # @param [LlmConfig] config
168
+ # @raise [MissingLlmConfigurationError] if not configured
169
+ # @return [void]
170
+ def validate_config(config)
171
+ config.open_ai_config.validate
172
+ end
173
+
174
+ ##
175
+ # Build the client instance.
176
+ # @param [LlmConfig] config
177
+ # @return [Client]
178
+ def build_client(config:)
179
+ Client.new(config: config.open_ai_config)
180
+ end
181
+ end
182
+
183
+ ##
184
+ # Error raised when OpenAI API requests fail.
185
+ class ApiError < LlmProviderError; end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../llm_errors"
4
+
5
+ module CleoQualityReview
6
+ module LlmProviders
7
+ module OpenAi
8
+ ##
9
+ # Configuration for OpenAI API access.
10
+ class Config
11
+ OPEN_AI_API_KEY = "CLEO_QUALITY_REVIEW_OPEN_AI_KEY"
12
+ TIMEOUT_SECONDS = "CLEO_QUALITY_REVIEW_TIMEOUT_SECONDS"
13
+ DEFAULT_MODEL = "gpt-5.5"
14
+ DEFAULT_TIMEOUT_SECONDS = 180
15
+
16
+ ##
17
+ # @param [Hash{String => String}] env environment variables
18
+ def initialize(env: ENV)
19
+ @env = env
20
+ end
21
+
22
+ ##
23
+ # @return [String] environment variable name for the API key
24
+ def api_key_env
25
+ OPEN_AI_API_KEY
26
+ end
27
+
28
+ ##
29
+ # @return [String] environment variable name for the request timeout
30
+ def timeout_seconds_env
31
+ TIMEOUT_SECONDS
32
+ end
33
+
34
+ ##
35
+ # @return [String] the OpenAI API key
36
+ # @raise [KeyError] if the API key is not set
37
+ def api_key
38
+ env.fetch(OPEN_AI_API_KEY)
39
+ end
40
+
41
+ ##
42
+ # @return [String] the model identifier to use
43
+ def model = DEFAULT_MODEL
44
+
45
+ ##
46
+ # @return [Integer] timeout in seconds for OpenAI HTTP requests
47
+ # @raise [ArgumentError] if the configured timeout is not a positive integer
48
+ def timeout_seconds
49
+ value = env.fetch(TIMEOUT_SECONDS, "").to_s.strip
50
+ return DEFAULT_TIMEOUT_SECONDS if value.empty?
51
+
52
+ Integer(value, 10).tap do |seconds|
53
+ raise ArgumentError if seconds <= 0
54
+ end
55
+ rescue ArgumentError
56
+ raise ArgumentError, "#{TIMEOUT_SECONDS} must be a positive integer number of seconds"
57
+ end
58
+
59
+ ##
60
+ # Check if the OpenAI configuration is complete.
61
+ # @return [Boolean]
62
+ def configured?
63
+ env.fetch(OPEN_AI_API_KEY, "").to_s.strip != ""
64
+ end
65
+
66
+ ##
67
+ # Validate that the configuration is complete.
68
+ # @raise [MissingLlmConfigurationError] if API key is missing
69
+ # @raise [ArgumentError] if timeout is invalid
70
+ # @return [void]
71
+ def validate
72
+ raise MissingLlmConfigurationError, "Missing OpenAI API key. Set #{api_key_env}." unless configured?
73
+
74
+ timeout_seconds
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :env
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../llm_errors"
4
+
5
+ module CleoQualityReview
6
+ module LlmProviders
7
+ ##
8
+ # Registry for available LLM provider implementations.
9
+ class Registry
10
+ class << self
11
+ ##
12
+ # Register an LLM provider implementation.
13
+ # @param [String, Symbol] name provider identifier
14
+ # @param [Class] provider_class provider class that responds to validate_config and build_client
15
+ # @return [nil]
16
+ def register(name, provider_class)
17
+ providers[provider_name(name)] = provider_class
18
+ nil
19
+ end
20
+
21
+ ##
22
+ # Resolve a provider name to a provider instance.
23
+ # @param [String, Symbol] name provider identifier
24
+ # @return [Object] provider instance
25
+ # @raise [UnsupportedLlmProviderError] if provider not found
26
+ def fetch(name)
27
+ providers.fetch(provider_name(name)).new
28
+ rescue KeyError
29
+ raise UnsupportedLlmProviderError, unsupported_provider_message(name)
30
+ end
31
+
32
+ ##
33
+ # @return [Array<String>] registered provider names
34
+ def registered
35
+ providers.keys.sort
36
+ end
37
+
38
+ ##
39
+ # @param [String, Symbol] name provider identifier
40
+ # @return [Boolean]
41
+ def registered?(name)
42
+ providers.key?(provider_name(name))
43
+ end
44
+
45
+ private
46
+
47
+ def provider_name(name)
48
+ name.to_s.downcase
49
+ end
50
+
51
+ def unsupported_provider_message(name)
52
+ "Unsupported LLM provider #{name.inspect}. Available: #{registered.join(', ')}"
53
+ end
54
+
55
+ def providers
56
+ @providers ||= {}
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ module LlmProviders
5
+ ##
6
+ # Stub provider implementation for tests and local development.
7
+ module Stub
8
+ ##
9
+ # Configuration for the stub LLM provider.
10
+ class Config
11
+ DEFAULT_RESPONSE = "This is a stub review response for testing."
12
+
13
+ class << self
14
+ ##
15
+ # Configure the response for all stub clients.
16
+ # @param [String, Proc] response fixed response or callable
17
+ # @return [void]
18
+ def response=(response)
19
+ @response = response
20
+ end
21
+
22
+ ##
23
+ # @return [String, Proc] the configured response
24
+ def response
25
+ @response || DEFAULT_RESPONSE
26
+ end
27
+
28
+ ##
29
+ # Reset to default response.
30
+ # @return [void]
31
+ def reset!
32
+ @response = nil
33
+ end
34
+ end
35
+
36
+ ##
37
+ # @param [Hash{String => String}] env environment variables (unused, for interface compatibility)
38
+ def initialize(env: ENV)
39
+ end
40
+
41
+ ##
42
+ # @return [String] the configured response
43
+ def response
44
+ self.class.response
45
+ end
46
+
47
+ ##
48
+ # @return [Boolean] always true for stub
49
+ def configured?
50
+ true
51
+ end
52
+ end
53
+
54
+ ##
55
+ # Stub LLM client, mirrors OpenAi::Client interface.
56
+ class Client
57
+ attr_reader :received_prompts
58
+
59
+ ##
60
+ # @param [Config] config stub configuration
61
+ def initialize(config:)
62
+ @config = config
63
+ @received_prompts = []
64
+ end
65
+
66
+ ##
67
+ # Generate a review by returning the configured response.
68
+ # @param [String] prompt the prompt sent
69
+ # @return [String] the configured response
70
+ def generate_review(prompt)
71
+ received_prompts << prompt
72
+ response = config.response
73
+
74
+ case response
75
+ when Proc
76
+ response.call(prompt)
77
+ else
78
+ response.to_s
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :config
85
+ end
86
+
87
+ ##
88
+ # Stub LLM provider adapter for LlmClient.
89
+ class Provider
90
+ ##
91
+ # Validate config - always passes for stub.
92
+ # @param [LlmConfig] config
93
+ # @return [void]
94
+ def validate_config(config)
95
+ end
96
+
97
+ ##
98
+ # Build the stub client.
99
+ # @param [LlmConfig] config
100
+ # @return [Client]
101
+ def build_client(config:)
102
+ Client.new(config: config.stub_config)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CleoQualityReview
4
+ ##
5
+ # Namespace for bundled LLM provider implementations.
6
+ module LlmProviders
7
+ require_relative "llm_providers/registry"
8
+ require_relative "llm_providers/open_ai"
9
+ require_relative "llm_providers/stub"
10
+
11
+ class << self
12
+ ##
13
+ # Register a new LLM provider for use.
14
+ # @param [String, Symbol] provider_name
15
+ # @param [Class] provider_class
16
+ # @return [nil]
17
+ def register(provider_name, provider_class)
18
+ Registry.register(provider_name.to_s, provider_class)
19
+ end
20
+
21
+ ##
22
+ # Resolve a registered LLM provider.
23
+ # @param [String, Symbol] provider_name
24
+ # @return [Object]
25
+ def fetch(provider_name)
26
+ Registry.fetch(provider_name.to_s)
27
+ end
28
+
29
+ ##
30
+ # @return [Array<String>] registered provider names
31
+ def registered
32
+ Registry.registered
33
+ end
34
+
35
+ ##
36
+ # Has a provider with the given name been registered?
37
+ # @param [String, Symbol] provider_name
38
+ # @return [Boolean]
39
+ def registered?(provider_name)
40
+ Registry.registered?(provider_name)
41
+ end
42
+ end
43
+ end
44
+ end