cleo_quality_review 0.3.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f7c748370590a154e611c6302e8bbfd9e5c3f8dc1b756e938ccba09101b6ee5
4
- data.tar.gz: 85e837b45af4b31c23e9f59d613653a481a5e172da0581df8edc96945c4f409c
3
+ metadata.gz: 85099120967159c27f15cfa154de5e081e125b08c6c2e3e910081d392caafa04
4
+ data.tar.gz: 7b7e772fa82cabf8ef0add3a6c39af9c16a0a02d95648f1060716b750eac9ac1
5
5
  SHA512:
6
- metadata.gz: 7f5f53a18a2cd9ef9e6e7c3456b416d4b2f0dcab0d36d3c4d75e70c1c97bb408659918c89d57ab284faf05e7111cd06f0fc39f34f1be0d08f611f48945fe9ace
7
- data.tar.gz: dabda2763af548f1ef2be712d6b3a0ec8ca1ca03330a249ea91b35972176a3f37785677dd39334ff207aae8374d586358a0c1c3a1a402f0e9ac94ab1c4dadff5
6
+ metadata.gz: '01243971003bf84478a7b7b73b2c12d6870bda49717e41b6d91333f83b3f0adce57dff6d646b0610734313f197375321bd8d788e744c7b04bae290dfd489ded1'
7
+ data.tar.gz: 7b839841dff2747bd7560b724fa06bbcb8e904b6577bfe25535e0351f2365d0a6b6787d2e116bee9528fdbba74e7047ee592241d22a34ed53b1e9089fbf0462d
@@ -21,9 +21,10 @@ module CleoQualityReview
21
21
  end
22
22
 
23
23
  ##
24
- # @return [String] combined tracked and untracked diff content
24
+ # @return [String] combined tracked and untracked diff content, or an empty
25
+ # string when there are no target files to review
25
26
  def to_s
26
- @to_s ||= [tracked_changes_diff, untracked_changes_diff].reject(&:empty?).join("\n")
27
+ @to_s ||= capture_diff
27
28
  end
28
29
 
29
30
  ##
@@ -36,11 +37,18 @@ module CleoQualityReview
36
37
 
37
38
  attr_reader :command_runner, :target_files, :base_ref, :strict_base
38
39
 
39
- def tracked_changes_diff
40
- command = ["git", "diff", diff_base]
41
- command.concat(["--", *target_files]) unless target_files.empty?
40
+ # When there are no target files there is nothing to review, so the diff is
41
+ # empty. We must not fall back to an unscoped +git diff+, which would capture
42
+ # the entire working tree (including untracked files such as installed gems
43
+ # under vendor/bundle) and overflow the LLM request.
44
+ def capture_diff
45
+ return "" if target_files.empty?
42
46
 
43
- command_runner.run(*command).stdout
47
+ [tracked_changes_diff, untracked_changes_diff].reject(&:empty?).join("\n")
48
+ end
49
+
50
+ def tracked_changes_diff
51
+ command_runner.run("git", "diff", diff_base, "--", *target_files).stdout
44
52
  end
45
53
 
46
54
  def untracked_changes_diff
@@ -50,13 +58,8 @@ module CleoQualityReview
50
58
  end
51
59
 
52
60
  def untracked_target_files
53
- command = ["git", "ls-files", "--others", "--exclude-standard"]
54
- empty_targets = target_files.empty?
55
- command.concat(["--", *target_files]) unless empty_targets
56
-
57
- command_runner.run(*command).stdout.lines.map(&:strip).select do |path|
58
- empty_targets || target_files.include?(path)
59
- end
61
+ command_runner.run("git", "ls-files", "--others", "--exclude-standard", "--", *target_files)
62
+ .stdout.lines.map(&:strip).select { |path| target_files.include?(path) }
60
63
  end
61
64
 
62
65
  def diff_base
@@ -5,7 +5,8 @@ require "optparse"
5
5
  require_relative "../cleo_quality_review"
6
6
  require_relative "command_runner"
7
7
  require_relative "formatter"
8
- require_relative "github_review_publisher"
8
+ require_relative "sticky_comment_publisher"
9
+ require_relative "incremental_base_resolver"
9
10
  require_relative "options"
10
11
  require_relative "runner"
11
12
  require_relative "run_artifacts"
@@ -58,7 +59,7 @@ module CleoQualityReview
58
59
 
59
60
  def run_one_shot(arguments)
60
61
  options = Options.parse(arguments)
61
- run = Runner.new(options: options, command_runner: command_runner).run
62
+ run = build_runner(options).run
62
63
  output = Formatter.new(run: run, command_runner: command_runner).format
63
64
  print_output(output)
64
65
  0
@@ -70,11 +71,19 @@ module CleoQualityReview
70
71
 
71
72
  def run_analyze(arguments)
72
73
  options = Options.parse(arguments)
73
- run = Runner.new(options: options, command_runner: command_runner).run
74
+ run = build_runner(options).run
74
75
  stdout.puts(run.review_id)
75
76
  0
76
77
  end
77
78
 
79
+ def build_runner(options)
80
+ Runner.new(
81
+ options: options,
82
+ command_runner: command_runner,
83
+ base_resolver: IncrementalBaseResolver.new(command_runner: command_runner),
84
+ )
85
+ end
86
+
78
87
  def run_render(arguments)
79
88
  options = Options.parse(arguments)
80
89
  run = RunArtifacts.load(review_id: options.validated_review_id).to_run(**options.run_loading_params)
@@ -86,7 +95,7 @@ module CleoQualityReview
86
95
  def run_publish_pr_review(arguments)
87
96
  options = Options.parse(arguments)
88
97
  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
98
+ output = StickyCommentPublisher.new(run: run, rendered_review: rendered_pr_review(options, run)).publish
90
99
  print_output(output)
91
100
  0
92
101
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ module CleoQualityReview
6
+ ##
7
+ # Runs independent, blocking work items across a bounded pool of threads.
8
+ #
9
+ # The pool is sized to the available processor count by default, so it
10
+ # naturally expands or contracts with the host. When there are more work
11
+ # items than workers, the surplus waits on an internal queue and is picked
12
+ # up as workers free up. Results are returned in the same order as the
13
+ # input items.
14
+ #
15
+ # Suited to I/O-bound work such as shelling out to external tools: while a
16
+ # worker thread blocks on a subprocess, Ruby releases the GIL so other
17
+ # workers make real progress.
18
+ class ConcurrentExecutor
19
+ ##
20
+ # @param [Integer, nil] max_workers explicit worker cap, or nil to use configuration
21
+ def initialize(max_workers: nil)
22
+ @max_workers = if max_workers
23
+ Configuration.max_concurrency_limit(max_workers)
24
+ else
25
+ Configuration.max_concurrency
26
+ end
27
+ end
28
+
29
+ ##
30
+ # Map over +items+ concurrently, preserving input order.
31
+ # @param [Array] items work items to process
32
+ # @yield [item] the work performed for each item
33
+ # @return [Array] results aligned with +items+
34
+ def map(items, &block)
35
+ return [] if items.empty?
36
+ return items.map(&block) if serial?(items.size)
37
+
38
+ process(items, &block)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :max_workers
44
+
45
+ ##
46
+ # Whether +item_count+ items should run serially rather than in a pool.
47
+ # @param [Integer] item_count number of work items
48
+ # @return [Boolean]
49
+ def serial?(item_count)
50
+ max_workers <= 1 || item_count == 1
51
+ end
52
+
53
+ ##
54
+ # Distribute +items+ across a bounded pool of worker threads.
55
+ # @param [Array] items work items to process
56
+ # @yield [item] the work performed for each item
57
+ # @return [Array] results aligned with +items+
58
+ def process(items, &block)
59
+ results = Array.new(items.size)
60
+ queue = work_queue(items)
61
+ run_workers(worker_count(results.size), queue, results, &block)
62
+ results
63
+ end
64
+
65
+ ##
66
+ # Number of workers to spawn: one per item, capped at +max_workers+.
67
+ # @param [Integer] item_count number of work items
68
+ # @return [Integer]
69
+ def worker_count(item_count)
70
+ [max_workers, item_count].min
71
+ end
72
+
73
+ ##
74
+ # Spawn +count+ worker threads that drain +queue+ into +results+, and join them.
75
+ # @param [Integer] count number of worker threads
76
+ # @param [Thread::Queue] queue source of +[index, item]+ pairs
77
+ # @param [Array] results destination, written by index
78
+ # @yield [item] the work performed for each item
79
+ # @return [void]
80
+ def run_workers(count, queue, results, &block)
81
+ workers = Array.new(count) { spawn_worker(queue, results, &block) }
82
+ workers.each(&:join)
83
+ end
84
+
85
+ ##
86
+ # Spawn one worker thread that drains +queue+ into +results+ by index.
87
+ # @param [Thread::Queue] queue source of +[index, item]+ pairs
88
+ # @param [Array] results destination, written by index
89
+ # @yield [item] the work performed for each item
90
+ # @return [Thread]
91
+ def spawn_worker(queue, results)
92
+ Thread.new do
93
+ Thread.current.report_on_exception = false
94
+ drain(queue) { |index, item| results[index] = yield(item) }
95
+ end
96
+ end
97
+
98
+ ##
99
+ # Build a queue of +[index, item]+ pairs for the workers to consume.
100
+ # @param [Array] items work items to process
101
+ # @return [Thread::Queue]
102
+ def work_queue(items)
103
+ queue = Thread::Queue.new
104
+ items.size.times { |index| queue << [index, items[index]] }
105
+ queue
106
+ end
107
+
108
+ ##
109
+ # Pop work off +queue+ until it is empty, yielding each pair.
110
+ # @param [Thread::Queue] queue source of +[index, item]+ pairs
111
+ # @yield [index, item] the indexed work item to process
112
+ # @return [void]
113
+ def drain(queue)
114
+ loop do
115
+ work = begin
116
+ queue.pop(true)
117
+ rescue ThreadError
118
+ break
119
+ end
120
+
121
+ yield(work)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "etc"
3
4
  require "set"
4
5
  require "yaml"
5
6
 
6
7
  module CleoQualityReview
7
8
  ##
8
- # Configuration for file include/exclude patterns
9
+ # Configuration for file include/exclude patterns and runtime defaults
9
10
  class Configuration
10
11
  DEFAULT_CONFIG_PATH = File.expand_path("../../config/default.yml", __dir__)
11
12
  LOCAL_CONFIG_PATH = ".cleo_quality_review.yaml"
@@ -24,6 +25,36 @@ module CleoQualityReview
24
25
  Loader.new(root: root).load
25
26
  end
26
27
 
28
+ ##
29
+ # Resolve the configured worker count for concurrent checks.
30
+ # @return [Integer] resolved worker count (at least 1)
31
+ def self.max_concurrency
32
+ max_concurrency_limit(env_max_concurrency || default_max_concurrency)
33
+ end
34
+
35
+ ##
36
+ # Clamp a worker count to a usable lower bound.
37
+ # @param [Integer] value worker count to clamp
38
+ # @return [Integer] clamped worker count
39
+ def self.max_concurrency_limit(value)
40
+ [value.to_i, 1].max
41
+ end
42
+
43
+ ##
44
+ # Read the max worker count from the environment, if configured.
45
+ # @return [Integer, nil] the configured count, or nil when unset/blank
46
+ # @raise [ArgumentError] if the environment value is not an integer
47
+ def self.env_max_concurrency
48
+ value = ENV["CLEO_QUALITY_REVIEW_MAX_CONCURRENCY"]
49
+ value && !value.strip.empty? ? Integer(value) : nil
50
+ end
51
+
52
+ ##
53
+ # @return [Integer] host processor count used as the default worker cap
54
+ def self.default_max_concurrency
55
+ Etc.nprocessors
56
+ end
57
+
27
58
  ##
28
59
  # @param [Hash] data parsed configuration data
29
60
  def initialize(data)
@@ -10,6 +10,10 @@ module CleoQualityReview
10
10
  ##
11
11
  # Formats quality review results using an LLM with format-specific prompts
12
12
  class Formatter
13
+ ##
14
+ # Format name of the shared configuration prompt applied to every run
15
+ CONFIGURATION_FORMAT = "configuration"
16
+
13
17
  ##
14
18
  # @param [Run] run the quality review run to format
15
19
  # @param [CommandRunner] command_runner for executing shell commands
@@ -23,10 +27,14 @@ module CleoQualityReview
23
27
  end
24
28
 
25
29
  ##
26
- # Format the run by generating an LLM review
27
- # @return [String] formatted review text
30
+ # Format the run by generating an LLM review. Returns an empty string
31
+ # without contacting the LLM when there are no files to review.
32
+ # @return [String] formatted review text, or an empty string when there is
33
+ # nothing to review
28
34
  def format
29
- llm_client.generate_review(prompt)
35
+ return "" unless run.reviewable?
36
+
37
+ llm_client.generate_review(prompt, instructions: configuration_prompt)
30
38
  end
31
39
 
32
40
  private
@@ -43,6 +51,13 @@ module CleoQualityReview
43
51
  ).build
44
52
  end
45
53
 
54
+ ##
55
+ # Shared review rules applied to every run regardless of output format.
56
+ # @return [String]
57
+ def configuration_prompt
58
+ PromptLoader.load(format: CONFIGURATION_FORMAT)
59
+ end
60
+
46
61
  ##
47
62
  # @return [RunArtifacts]
48
63
  def artifacts
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module CleoQualityReview
8
+ ##
9
+ # Thin authenticated HTTP client for the GitHub REST API
10
+ class GitHubClient
11
+ API_VERSION = "2022-11-28"
12
+ DEFAULT_API_URL = "https://api.github.com"
13
+
14
+ ##
15
+ # Wrapped HTTP response
16
+ #
17
+ # @!attribute [r] status_code
18
+ # @return [Integer] HTTP status code
19
+ # @!attribute [r] body
20
+ # @return [String] raw response body
21
+ Response = Struct.new(:status_code, :body, keyword_init: true) do
22
+ ##
23
+ # @return [Boolean] whether the response status is in the 2xx range
24
+ def success?
25
+ (200..299).cover?(status_code.to_i)
26
+ end
27
+ end
28
+
29
+ ##
30
+ # @param [String] token GitHub API token
31
+ # @param [String] api_url base GitHub API URL
32
+ def initialize(token:, api_url: DEFAULT_API_URL)
33
+ @token = token
34
+ @api_url = api_url.to_s.strip.empty? ? DEFAULT_API_URL : api_url
35
+ end
36
+
37
+ ##
38
+ # Perform an authenticated GET request
39
+ # @param [String] path API path beginning with "/"
40
+ # @return [Response]
41
+ def get(path)
42
+ request_json(:get, uri_for(path))
43
+ end
44
+
45
+ ##
46
+ # Perform an authenticated POST request
47
+ # @param [String] path API path beginning with "/"
48
+ # @param [Hash] body request body serialised as JSON
49
+ # @return [Response]
50
+ def post(path, body)
51
+ request_json(:post, uri_for(path), body)
52
+ end
53
+
54
+ ##
55
+ # Perform an authenticated PATCH request
56
+ # @param [String] path API path beginning with "/"
57
+ # @param [Hash] body request body serialised as JSON
58
+ # @return [Response]
59
+ def patch(path, body)
60
+ request_json(:patch, uri_for(path), body)
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :token, :api_url
66
+
67
+ def uri_for(path)
68
+ URI("#{api_url}#{path}")
69
+ end
70
+
71
+ def request_json(method, uri, body = nil)
72
+ wrap_response(perform_request(uri, build_request(method, uri, body)))
73
+ end
74
+
75
+ def build_request(method, uri, body)
76
+ request = request_class(method).new(uri)
77
+ apply_headers(request)
78
+ request.body = JSON.generate(body) if body
79
+ request
80
+ end
81
+
82
+ def request_class(method)
83
+ {
84
+ get: Net::HTTP::Get,
85
+ post: Net::HTTP::Post,
86
+ patch: Net::HTTP::Patch,
87
+ }.fetch(method) { raise ArgumentError, "Unsupported HTTP method #{method.inspect}" }
88
+ end
89
+
90
+ def apply_headers(request)
91
+ github_headers.each { |key, value| request[key] = value }
92
+ end
93
+
94
+ def github_headers
95
+ {
96
+ "Accept" => "application/vnd.github+json",
97
+ "Authorization" => "Bearer #{token}",
98
+ "Content-Type" => "application/json",
99
+ "User-Agent" => "cleo-quality-review",
100
+ "X-GitHub-Api-Version" => API_VERSION,
101
+ }
102
+ end
103
+
104
+ def perform_request(uri, request)
105
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
106
+ http.request(request)
107
+ end
108
+ end
109
+
110
+ def wrap_response(response)
111
+ Response.new(status_code: response.code.to_i, body: response.body.to_s)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "github_client"
6
+ require_relative "llm_errors"
7
+ require_relative "sticky_comment_builder"
8
+
9
+ module CleoQualityReview
10
+ ##
11
+ # Resolves the git base for an incremental review.
12
+ #
13
+ # On a pull request that cleo-quality-review has already reviewed, this
14
+ # returns the previously-reviewed commit recorded on the sticky pull request
15
+ # comment, provided it is still an ancestor of the current head, so only
16
+ # changes made since that review are analysed. It falls back to +nil+
17
+ # (meaning "review the full diff") outside a pull request context, when no
18
+ # sticky comment survives in history, or on any lookup error.
19
+ class IncrementalBaseResolver
20
+ MARKER_PREFIX = StickyCommentBuilder::MARKER_PREFIX
21
+ COMMIT_PATTERN = /#{Regexp.escape(MARKER_PREFIX)}\s*commit=(\S+)\s*-->/
22
+ DISABLED_VALUES = %w[0 false no off].freeze
23
+ ENABLED_ENV_KEY = "CLEO_QUALITY_REVIEW_INCREMENTAL"
24
+ COMMENTS_PER_PAGE = 100
25
+ MAX_COMMENT_PAGES = 20
26
+
27
+ ##
28
+ # @param [CommandRunner] command_runner for executing git commands
29
+ # @param [Hash{String => String}] env process environment
30
+ # @param [GitHubClient, nil] client GitHub API client (built from env when omitted)
31
+ def initialize(command_runner:, env: ENV, client: nil)
32
+ @command_runner = command_runner
33
+ @env = env
34
+ @client = client
35
+ end
36
+
37
+ ##
38
+ # Resolve the incremental base commit.
39
+ # @param [String] head git ref for the current head
40
+ # @return [String, nil] commit SHA to diff against, or nil to review the full diff
41
+ def resolve(head: "HEAD")
42
+ return nil unless incremental_lookup_available?
43
+
44
+ reviewed_commit(head)
45
+ rescue StandardError => error
46
+ warn("cleo-quality-review: incremental base lookup failed (#{error.message}); reviewing the full diff")
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :command_runner, :env
53
+
54
+ ##
55
+ # @return [Boolean] whether an incremental lookup can run in this context
56
+ def incremental_lookup_available?
57
+ enabled? && !pull_request_number.nil? && !token.nil? && !repository.nil?
58
+ end
59
+
60
+ def reviewed_commit(head)
61
+ sha = sticky_comment_commit_sha
62
+ sha if sha && ancestor?(sha, head)
63
+ end
64
+
65
+ def sticky_comment_commit_sha
66
+ sticky_comment = comments.find { |comment| quality_review?(comment) }
67
+ sticky_comment && sticky_comment.fetch("body").to_s[COMMIT_PATTERN, 1]
68
+ end
69
+
70
+ ##
71
+ # Fetch every issue comment, following pagination so our sticky comment is
72
+ # not missed on pull requests with more than one page of comments.
73
+ # @return [Array<Hash>]
74
+ def comments
75
+ (1..MAX_COMMENT_PAGES).each_with_object([]) do |page, all|
76
+ page_comments = comments_page(page)
77
+ all.concat(page_comments)
78
+ break all if page_comments.length < COMMENTS_PER_PAGE
79
+ end
80
+ end
81
+
82
+ def comments_page(page)
83
+ response = client.get("/repos/#{repository}/issues/#{pull_request_number}/comments?per_page=#{COMMENTS_PER_PAGE}&page=#{page}")
84
+ raise Error, "GitHub comment lookup returned status #{response.status_code}" unless response.success?
85
+
86
+ parsed = JSON.parse(response.body)
87
+ parsed.is_a?(Array) ? parsed : []
88
+ end
89
+
90
+ ##
91
+ # Only trust a bot-authored comment that carries our marker.
92
+ # @param [Hash] comment
93
+ # @return [Boolean]
94
+ def quality_review?(comment)
95
+ bot_authored?(comment) && marked?(comment)
96
+ end
97
+
98
+ def bot_authored?(comment)
99
+ comment.dig("user", "type") == "Bot"
100
+ end
101
+
102
+ def marked?(comment)
103
+ comment.fetch("body") { "" }.to_s.include?(MARKER_PREFIX)
104
+ end
105
+
106
+ def ancestor?(sha, head)
107
+ command_runner.run("git", "merge-base", "--is-ancestor", sha, head).success?
108
+ end
109
+
110
+ def enabled?
111
+ !DISABLED_VALUES.include?(env.fetch(ENABLED_ENV_KEY) { "" }.to_s.strip.downcase)
112
+ end
113
+
114
+ def pull_request_number
115
+ return @pull_request_number if defined?(@pull_request_number)
116
+
117
+ @pull_request_number = event && (event["number"] || event.dig("pull_request", "number"))
118
+ end
119
+
120
+ def event
121
+ return @event if defined?(@event)
122
+
123
+ @event = load_event
124
+ end
125
+
126
+ def load_event
127
+ path = env["GITHUB_EVENT_PATH"]
128
+ return nil if path.to_s.empty? || !File.file?(path)
129
+
130
+ JSON.parse(File.read(path))
131
+ rescue JSON::ParserError
132
+ nil
133
+ end
134
+
135
+ def token
136
+ value = env["GITHUB_TOKEN"].to_s
137
+ value unless value.empty?
138
+ end
139
+
140
+ def repository
141
+ value = env["GITHUB_REPOSITORY"].to_s
142
+ value unless value.empty?
143
+ end
144
+
145
+ def api_url
146
+ env.fetch("GITHUB_API_URL") { GitHubClient::DEFAULT_API_URL }
147
+ end
148
+
149
+ def client
150
+ @client ||= GitHubClient.new(token: token, api_url: api_url)
151
+ end
152
+ end
153
+ end
@@ -19,10 +19,12 @@ module CleoQualityReview
19
19
 
20
20
  ##
21
21
  # Generate a review from the given prompt
22
- # @param [String] prompt
22
+ # @param [String] prompt the format-specific prompt sent as input
23
+ # @param [String, nil] instructions shared configuration prompt applied to
24
+ # every run
23
25
  # @return [String] the generated review
24
- def generate_review(prompt)
25
- generate_with_logging(prompt)
26
+ def generate_review(prompt, instructions: nil)
27
+ generate_with_logging(prompt, instructions)
26
28
  rescue StandardError => e
27
29
  log_error(prompt, e)
28
30
  raise
@@ -32,8 +34,10 @@ module CleoQualityReview
32
34
 
33
35
  attr_reader :config, :logger
34
36
 
35
- def generate_with_logging(prompt)
36
- provider_client.generate_review(prompt).tap { |response| log_success(prompt, response) }
37
+ def generate_with_logging(prompt, instructions)
38
+ provider_client.generate_review(prompt, instructions: instructions).tap do |response|
39
+ log_success(prompt, response)
40
+ end
37
41
  end
38
42
 
39
43
  def log_success(prompt, response)
@@ -89,11 +89,13 @@ module CleoQualityReview
89
89
 
90
90
  ##
91
91
  # Generate a review using the OpenAI Responses API.
92
- # @param [String] prompt the prompt to send
92
+ # @param [String] prompt the format-specific prompt to send as input
93
+ # @param [String, nil] instructions shared configuration prompt sent as
94
+ # the system-level instructions applied to every run
93
95
  # @return [String] generated review text
94
96
  # @raise [ApiError] if the API request fails
95
- def generate_review(prompt)
96
- response = execute_request(prompt)
97
+ def generate_review(prompt, instructions: nil)
98
+ response = execute_request(request_body(prompt, instructions))
97
99
  parse_response(response)
98
100
  end
99
101
 
@@ -101,9 +103,9 @@ module CleoQualityReview
101
103
 
102
104
  attr_reader :config, :http_transport
103
105
 
104
- def execute_request(prompt)
106
+ def execute_request(body)
105
107
  timeout_seconds = config.timeout_seconds
106
- http_transport.post_json(build_request(prompt, timeout_seconds))
108
+ http_transport.post_json(build_request(body, timeout_seconds))
107
109
  rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
108
110
  raise ApiError, timeout_error_message(timeout_seconds, e)
109
111
  end
@@ -116,15 +118,21 @@ module CleoQualityReview
116
118
  raise ApiError, "OpenAI Responses API returned invalid JSON: #{e.message}"
117
119
  end
118
120
 
119
- def build_request(prompt, timeout_seconds)
121
+ def build_request(body, timeout_seconds)
120
122
  HttpRequest.new(
121
123
  uri: RESPONSES_API_URL,
122
124
  headers: headers,
123
- body: { model: config.model, input: prompt },
125
+ body: body,
124
126
  timeout_seconds: timeout_seconds,
125
127
  )
126
128
  end
127
129
 
130
+ def request_body(prompt, instructions)
131
+ body = { model: config.model, input: prompt }
132
+ body[:instructions] = instructions unless instructions.to_s.strip.empty?
133
+ body
134
+ end
135
+
128
136
  def timeout_error_message(timeout_seconds, error)
129
137
  "OpenAI Responses API request timed out after #{timeout_seconds} seconds: #{error.class}: #{error.message}"
130
138
  end