cleo_quality_review 0.3.0 → 0.4.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: 9277c83af1fccd6045fde0ef6879b6fa7d9d2fc0d1848e1401d58f1889a6c458
4
+ data.tar.gz: c2d158fd19d8566edad3556ed46b020c34ec2a2c7ade2199d3b06b2c612b1e6c
5
5
  SHA512:
6
- metadata.gz: 7f5f53a18a2cd9ef9e6e7c3456b416d4b2f0dcab0d36d3c4d75e70c1c97bb408659918c89d57ab284faf05e7111cd06f0fc39f34f1be0d08f611f48945fe9ace
7
- data.tar.gz: dabda2763af548f1ef2be712d6b3a0ec8ca1ca03330a249ea91b35972176a3f37785677dd39334ff207aae8374d586358a0c1c3a1a402f0e9ac94ab1c4dadff5
6
+ metadata.gz: 6fbd671e9068c52268bf5f2d139817a330919c630ebe59773e785a2e25f5fa8039eb34280ad0b58cb31de984404ee8b59ab5deb4469d9246a60f59f088ccbb6d
7
+ data.tar.gz: 37731db93423a4da4948a00ab7550809d6f9a1dabc69cb313e2ba066e6239fe0160b3a12c560da66976ecf324d344eee5398cd95b0b1778b4ea07441d46620f1
@@ -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
@@ -6,6 +6,7 @@ require_relative "../cleo_quality_review"
6
6
  require_relative "command_runner"
7
7
  require_relative "formatter"
8
8
  require_relative "github_review_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)
@@ -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,104 @@
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
+ private
55
+
56
+ attr_reader :token, :api_url
57
+
58
+ def uri_for(path)
59
+ URI("#{api_url}#{path}")
60
+ end
61
+
62
+ def request_json(method, uri, body = nil)
63
+ wrap_response(perform_request(uri, build_request(method, uri, body)))
64
+ end
65
+
66
+ def build_request(method, uri, body)
67
+ request = request_class(method).new(uri)
68
+ apply_headers(request)
69
+ request.body = JSON.generate(body) if body
70
+ request
71
+ end
72
+
73
+ def request_class(method)
74
+ {
75
+ get: Net::HTTP::Get,
76
+ post: Net::HTTP::Post,
77
+ }.fetch(method) { raise ArgumentError, "Unsupported HTTP method #{method.inspect}" }
78
+ end
79
+
80
+ def apply_headers(request)
81
+ github_headers.each { |key, value| request[key] = value }
82
+ end
83
+
84
+ def github_headers
85
+ {
86
+ "Accept" => "application/vnd.github+json",
87
+ "Authorization" => "Bearer #{token}",
88
+ "Content-Type" => "application/json",
89
+ "User-Agent" => "cleo-quality-review",
90
+ "X-GitHub-Api-Version" => API_VERSION,
91
+ }
92
+ end
93
+
94
+ def perform_request(uri, request)
95
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
96
+ http.request(request)
97
+ end
98
+ end
99
+
100
+ def wrap_response(response)
101
+ Response.new(status_code: response.code.to_i, body: response.body.to_s)
102
+ end
103
+ end
104
+ end
@@ -95,12 +95,20 @@ module CleoQualityReview
95
95
  end
96
96
 
97
97
  def parsed_review
98
- @parsed_review ||= begin
99
- parsed = JSON.parse(rendered_review.to_s)
100
- raise Error, "pr_review JSON must be an object" unless parsed.is_a?(Hash)
98
+ @parsed_review ||= parse_rendered_review
99
+ end
101
100
 
102
- parsed
103
- end
101
+ # A blank rendered review (e.g. the render step produced no output because
102
+ # there were no reviewable changes) means there is nothing to publish, so
103
+ # treat it as an empty review rather than failing to parse it as JSON.
104
+ def parse_rendered_review
105
+ content = rendered_review.to_s.strip
106
+ return {} if content.empty?
107
+
108
+ parsed = JSON.parse(content)
109
+ raise Error, "pr_review JSON must be an object" unless parsed.is_a?(Hash)
110
+
111
+ parsed
104
112
  rescue JSON::ParserError => e
105
113
  raise Error, "pr_review output was not valid JSON: #{e.message}"
106
114
  end
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "net/http"
5
- require "uri"
6
4
 
5
+ require_relative "github_client"
7
6
  require_relative "github_review_builder"
8
7
  require_relative "llm_errors"
9
8
 
@@ -11,16 +10,16 @@ module CleoQualityReview
11
10
  ##
12
11
  # Publishes quality review findings as a GitHub pull request review
13
12
  class GitHubReviewPublisher
14
- API_VERSION = "2022-11-28"
15
-
16
13
  ##
17
14
  # @param [Run] run completed quality review run
18
15
  # @param [String] rendered_review JSON produced by the pr_review formatter
19
16
  # @param [Hash{String => String}] env process environment
20
- def initialize(run:, rendered_review:, env: ENV)
17
+ # @param [GitHubClient, nil] client GitHub API client (built from env when omitted)
18
+ def initialize(run:, rendered_review:, env: ENV, client: nil)
21
19
  @run = run
22
20
  @rendered_review = rendered_review
23
21
  @env = env
22
+ @client = client
24
23
  end
25
24
 
26
25
  ##
@@ -45,22 +44,16 @@ module CleoQualityReview
45
44
  end
46
45
 
47
46
  def post_review
48
- response = request_json(:post, reviews_uri, builder.payload(commit_id: head_sha))
47
+ response = client.post(reviews_path, builder.payload(commit_id: head_sha))
49
48
  raise Error, "GitHub PR review publication failed with status #{response.status_code}: #{response.body}" unless response.success?
50
49
 
51
50
  "Published PR review for review ID #{run.review_id}."
52
51
  end
53
52
 
54
- GitHubResponse = Struct.new(:status_code, :body, keyword_init: true) do
55
- def success?
56
- (200..299).cover?(status_code.to_i)
57
- end
58
- end
59
-
60
53
  attr_reader :env, :rendered_review, :run
61
54
 
62
55
  def already_published?
63
- response = request_json(:get, reviews_uri)
56
+ response = client.get(reviews_path)
64
57
  body = response.body
65
58
  raise Error, "GitHub PR review lookup failed with status #{response.status_code}: #{body}" unless response.success?
66
59
 
@@ -69,6 +62,10 @@ module CleoQualityReview
69
62
  end
70
63
  end
71
64
 
65
+ def client
66
+ @client ||= GitHubClient.new(token: token, api_url: api_url)
67
+ end
68
+
72
69
  def builder
73
70
  @builder ||= GitHubReviewBuilder.new(run: run, rendered_review: rendered_review)
74
71
  end
@@ -77,8 +74,8 @@ module CleoQualityReview
77
74
  event.fetch("pull_request", nil).is_a?(Hash)
78
75
  end
79
76
 
80
- def reviews_uri
81
- URI("#{api_url}/repos/#{repository}/pulls/#{pull_request_number}/reviews")
77
+ def reviews_path
78
+ "/repos/#{repository}/pulls/#{pull_request_number}/reviews"
82
79
  end
83
80
 
84
81
  def pull_request_number
@@ -104,47 +101,5 @@ module CleoQualityReview
104
101
  def token
105
102
  env.fetch("GITHUB_TOKEN")
106
103
  end
107
-
108
- def request_json(method, uri, body = nil)
109
- wrap_response(perform_request(uri, build_request(method, uri, body)))
110
- end
111
-
112
- def build_request(method, uri, body)
113
- request = request_class(method).new(uri)
114
- apply_headers(request)
115
- request.body = JSON.generate(body) if body
116
- request
117
- end
118
-
119
- def request_class(method)
120
- {
121
- get: Net::HTTP::Get,
122
- post: Net::HTTP::Post,
123
- }.fetch(method) { raise ArgumentError, "Unsupported HTTP method #{method.inspect}" }
124
- end
125
-
126
- def apply_headers(request)
127
- github_headers.each { |key, value| request[key] = value }
128
- end
129
-
130
- def github_headers
131
- {
132
- "Accept" => "application/vnd.github+json",
133
- "Authorization" => "Bearer #{token}",
134
- "Content-Type" => "application/json",
135
- "User-Agent" => "cleo-quality-review",
136
- "X-GitHub-Api-Version" => API_VERSION,
137
- }
138
- end
139
-
140
- def perform_request(uri, request)
141
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
142
- http.request(request)
143
- end
144
- end
145
-
146
- def wrap_response(response)
147
- GitHubResponse.new(status_code: response.code.to_i, body: response.body.to_s)
148
- end
149
104
  end
150
105
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "github_client"
6
+ require_relative "llm_errors"
7
+
8
+ module CleoQualityReview
9
+ ##
10
+ # Resolves the git base for an incremental review.
11
+ #
12
+ # On a pull request that cleo-quality-review has already reviewed, this
13
+ # returns the most recent previously-reviewed commit that is still an
14
+ # ancestor of the current head, so only changes made since that review are
15
+ # analysed. It falls back to +nil+ (meaning "review the full diff") outside a
16
+ # pull request context, when no prior review survives in history, or on any
17
+ # lookup error.
18
+ class IncrementalBaseResolver
19
+ REVIEW_MARKER_PREFIX = "<!-- cleo-quality-review:"
20
+ DISABLED_VALUES = %w[0 false no off].freeze
21
+ ENABLED_ENV_KEY = "CLEO_QUALITY_REVIEW_INCREMENTAL"
22
+ REVIEWS_PER_PAGE = 100
23
+ MAX_REVIEW_PAGES = 20
24
+
25
+ ##
26
+ # @param [CommandRunner] command_runner for executing git commands
27
+ # @param [Hash{String => String}] env process environment
28
+ # @param [GitHubClient, nil] client GitHub API client (built from env when omitted)
29
+ def initialize(command_runner:, env: ENV, client: nil)
30
+ @command_runner = command_runner
31
+ @env = env
32
+ @client = client
33
+ end
34
+
35
+ ##
36
+ # Resolve the incremental base commit.
37
+ # @param [String] head git ref for the current head
38
+ # @return [String, nil] commit SHA to diff against, or nil to review the full diff
39
+ def resolve(head: "HEAD")
40
+ return nil unless incremental_lookup_available?
41
+
42
+ newest_reviewed_ancestor(head)
43
+ rescue StandardError => error
44
+ warn("cleo-quality-review: incremental base lookup failed (#{error.message}); reviewing the full diff")
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :command_runner, :env
51
+
52
+ ##
53
+ # @return [Boolean] whether an incremental lookup can run in this context
54
+ def incremental_lookup_available?
55
+ enabled? && !pull_request_number.nil? && !token.nil? && !repository.nil?
56
+ end
57
+
58
+ def newest_reviewed_ancestor(head)
59
+ reviewed_commit_ids.find { |sha| ancestor?(sha, head) }
60
+ end
61
+
62
+ def reviewed_commit_ids
63
+ reviews
64
+ .select { |review| quality_review?(review) }
65
+ .sort_by { |review| review["submitted_at"].to_s }
66
+ .reverse
67
+ .filter_map { |review| review["commit_id"] }
68
+ .reject { |sha| sha.to_s.strip.empty? }
69
+ .uniq
70
+ end
71
+
72
+ ##
73
+ # Fetch every submitted review, following pagination so the newest reviews
74
+ # are not missed on pull requests with more than one page of reviews.
75
+ # @return [Array<Hash>]
76
+ def reviews
77
+ (1..MAX_REVIEW_PAGES).each_with_object([]) do |page, all|
78
+ page_reviews = reviews_page(page)
79
+ all.concat(page_reviews)
80
+ break all if page_reviews.length < REVIEWS_PER_PAGE
81
+ end
82
+ end
83
+
84
+ def reviews_page(page)
85
+ response = client.get("/repos/#{repository}/pulls/#{pull_request_number}/reviews?per_page=#{REVIEWS_PER_PAGE}&page=#{page}")
86
+ raise Error, "GitHub review lookup returned status #{response.status_code}" unless response.success?
87
+
88
+ parsed = JSON.parse(response.body)
89
+ parsed.is_a?(Array) ? parsed : []
90
+ end
91
+
92
+ ##
93
+ # Only trust bot-authored reviews that carry our marker. A human contributor
94
+ # could otherwise forge the marker in their own review and steer the base
95
+ # past changes the tool never analysed.
96
+ # @param [Hash] review
97
+ # @return [Boolean]
98
+ def quality_review?(review)
99
+ bot_authored?(review) && marked?(review)
100
+ end
101
+
102
+ def bot_authored?(review)
103
+ review.dig("user", "type") == "Bot"
104
+ end
105
+
106
+ def marked?(review)
107
+ review.fetch("body") { "" }.to_s.include?(REVIEW_MARKER_PREFIX)
108
+ end
109
+
110
+ def ancestor?(sha, head)
111
+ command_runner.run("git", "merge-base", "--is-ancestor", sha, head).success?
112
+ end
113
+
114
+ def enabled?
115
+ !DISABLED_VALUES.include?(env.fetch(ENABLED_ENV_KEY) { "" }.to_s.strip.downcase)
116
+ end
117
+
118
+ def pull_request_number
119
+ return @pull_request_number if defined?(@pull_request_number)
120
+
121
+ @pull_request_number = event && (event["number"] || event.dig("pull_request", "number"))
122
+ end
123
+
124
+ def event
125
+ return @event if defined?(@event)
126
+
127
+ @event = load_event
128
+ end
129
+
130
+ def load_event
131
+ path = env["GITHUB_EVENT_PATH"]
132
+ return nil if path.to_s.empty? || !File.file?(path)
133
+
134
+ JSON.parse(File.read(path))
135
+ rescue JSON::ParserError
136
+ nil
137
+ end
138
+
139
+ def token
140
+ value = env["GITHUB_TOKEN"].to_s
141
+ value unless value.empty?
142
+ end
143
+
144
+ def repository
145
+ value = env["GITHUB_REPOSITORY"].to_s
146
+ value unless value.empty?
147
+ end
148
+
149
+ def api_url
150
+ env.fetch("GITHUB_API_URL") { GitHubClient::DEFAULT_API_URL }
151
+ end
152
+
153
+ def client
154
+ @client ||= GitHubClient.new(token: token, api_url: api_url)
155
+ end
156
+ end
157
+ 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
@@ -54,21 +54,25 @@ module CleoQualityReview
54
54
  ##
55
55
  # Stub LLM client, mirrors OpenAi::Client interface.
56
56
  class Client
57
- attr_reader :received_prompts
57
+ attr_reader :received_prompts, :received_instructions
58
58
 
59
59
  ##
60
60
  # @param [Config] config stub configuration
61
61
  def initialize(config:)
62
62
  @config = config
63
63
  @received_prompts = []
64
+ @received_instructions = []
64
65
  end
65
66
 
66
67
  ##
67
68
  # Generate a review by returning the configured response.
68
- # @param [String] prompt the prompt sent
69
+ # @param [String] prompt the format-specific prompt sent as input
70
+ # @param [String, nil] instructions shared configuration prompt applied
71
+ # to every run
69
72
  # @return [String] the configured response
70
- def generate_review(prompt)
73
+ def generate_review(prompt, instructions: nil)
71
74
  received_prompts << prompt
75
+ received_instructions << instructions
72
76
  response = config.response
73
77
 
74
78
  case response
@@ -25,7 +25,9 @@ module CleoQualityReview
25
25
  # @return [Array<String>] checks to exclude
26
26
  # @!attribute [r] changed
27
27
  # @return [Boolean] whether to filter to changed files only
28
- ParseResult = Struct.new(:format, :checks, :files, :exclude, :changed, :base, :log, :review_id, :review_file, keyword_init: true) do
28
+ # @!attribute [r] jobs
29
+ # @return [Integer, nil] max checks to run in parallel, or nil to auto-size
30
+ ParseResult = Struct.new(:format, :checks, :files, :exclude, :changed, :base, :log, :review_id, :review_file, :jobs, keyword_init: true) do
29
31
  ##
30
32
  # @return [String] validated review_id
31
33
  # @raise [OptionParser::MissingArgument] if review_id is blank
@@ -64,6 +66,7 @@ module CleoQualityReview
64
66
  @log = false
65
67
  @review_id = nil
66
68
  @review_file = nil
69
+ @jobs = nil
67
70
  end
68
71
 
69
72
  ##
@@ -85,17 +88,19 @@ module CleoQualityReview
85
88
  log: log,
86
89
  review_id: review_id,
87
90
  review_file: review_file,
91
+ jobs: jobs,
88
92
  )
89
93
  end
90
94
 
91
95
  private
92
96
 
93
- attr_reader :argv, :format, :checks, :files, :exclude, :changed, :base, :log, :review_id, :review_file
97
+ attr_reader :argv, :format, :checks, :files, :exclude, :changed, :base, :log, :review_id, :review_file, :jobs
94
98
 
95
99
  def parser
96
100
  OptionParser.new do |opts|
97
101
  opts.banner = "Usage: check_quality [options] [files...]"
98
102
  register_options(opts)
103
+ register_help_option(opts)
99
104
  end
100
105
  end
101
106
 
@@ -104,7 +109,13 @@ module CleoQualityReview
104
109
  register_check_options(opts)
105
110
  register_target_options(opts)
106
111
  register_output_options(opts)
107
- register_help_option(opts)
112
+ register_jobs_option(opts)
113
+ end
114
+
115
+ def register_jobs_option(opts)
116
+ opts.on("-j", "--jobs N", Integer, "Max checks to run in parallel (default: CPU cores)") do |value|
117
+ @jobs = value
118
+ end
108
119
  end
109
120
 
110
121
  def register_format_option(opts)
@@ -38,6 +38,14 @@ module CleoQualityReview
38
38
  :log,
39
39
  keyword_init: true,
40
40
  ) do
41
+ ##
42
+ # Whether the run has any files to review. Runs with no target files
43
+ # (e.g. a branch that only changes non-Ruby files) have nothing to analyse.
44
+ # @return [Boolean]
45
+ def reviewable?
46
+ !Array(target_files).empty?
47
+ end
48
+
41
49
  ##
42
50
  # Convert the run to a hash representation
43
51
  # @return [Hash{Symbol => Object}]
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "forwardable"
4
5
  require "json"
5
6
 
6
7
  require_relative "changes_diff"
7
8
  require_relative "checks"
8
9
  require_relative "command_runner"
10
+ require_relative "concurrent_executor"
9
11
  require_relative "git_diff_base"
10
12
  require_relative "run"
11
13
  require_relative "run_artifacts"
@@ -15,6 +17,8 @@ module CleoQualityReview
15
17
  ##
16
18
  # Orchestrates a complete quality review run
17
19
  class Runner
20
+ extend Forwardable
21
+
18
22
  ##
19
23
  # Grouped values resolved at the start of an analysis run
20
24
  AnalysisContext = Struct.new(:timestamp, :base_ref, :target, :changes, :review_id, :check_classes, keyword_init: true) do
@@ -32,16 +36,28 @@ module CleoQualityReview
32
36
  end
33
37
  end
34
38
 
39
+ ##
40
+ # Runtime collaborators for a quality review run
41
+ Dependencies = Struct.new(:command_runner, :clock, :check_registry, :base_resolver, :executor, keyword_init: true) do
42
+ def self.for(options, overrides)
43
+ new(
44
+ **{
45
+ command_runner: CommandRunner.new,
46
+ clock: Time,
47
+ check_registry: Checks,
48
+ base_resolver: nil,
49
+ executor: ConcurrentExecutor.new(max_workers: options.jobs),
50
+ }.merge(overrides),
51
+ )
52
+ end
53
+ end
54
+
35
55
  ##
36
56
  # @param [Options::ParseResult] options parsed command-line options
37
- # @param [CommandRunner] command_runner for executing shell commands
38
- # @param [#now] clock time source for timestamps
39
- # @param [CheckRegistry] check_registry registry for resolving check names
40
- def initialize(options:, command_runner: CommandRunner.new, clock: Time, check_registry: Checks)
57
+ # @param [Hash] dependencies optional runtime collaborators for tests or alternate runners
58
+ def initialize(options:, **dependencies)
41
59
  @options = options
42
- @command_runner = command_runner
43
- @clock = clock
44
- @check_registry = check_registry
60
+ @dependencies = Dependencies.for(options, dependencies)
45
61
  end
46
62
 
47
63
  ##
@@ -57,7 +73,9 @@ module CleoQualityReview
57
73
 
58
74
  private
59
75
 
60
- attr_reader :options, :command_runner, :clock, :check_registry
76
+ attr_reader :options, :dependencies
77
+ def_delegators :dependencies, :command_runner, :clock, :check_registry, :base_resolver, :executor
78
+ private :command_runner, :clock, :check_registry, :base_resolver, :executor
61
79
 
62
80
  def epoch_milliseconds
63
81
  (clock.now.to_r * 1_000).to_i
@@ -127,7 +145,9 @@ module CleoQualityReview
127
145
  end
128
146
 
129
147
  def run_checks(check_classes, ruby_files, timestamp)
130
- check_classes.map do |check_class|
148
+ return [] if ruby_files.empty?
149
+
150
+ executor.map(check_classes) do |check_class|
131
151
  check_class.new(command_runner: command_runner, timestamp: timestamp).run(ruby_files)
132
152
  end
133
153
  end
@@ -170,6 +190,10 @@ module CleoQualityReview
170
190
  end
171
191
 
172
192
  def base_ref
193
+ @base_ref ||= base_resolver&.resolve || default_base_ref
194
+ end
195
+
196
+ def default_base_ref
173
197
  options.base || GitDiffBase::DEFAULT_BASE_REF
174
198
  end
175
199
  end
@@ -3,5 +3,5 @@
3
3
  module CleoQualityReview
4
4
  ##
5
5
  # Gem version
6
- VERSION = "0.3.0"
6
+ VERSION = "0.4.0"
7
7
  end
data/prompts/agent.md CHANGED
@@ -1,12 +1,8 @@
1
1
  You are reviewing Ruby code quality findings for consumption by AI coding assistants.
2
2
 
3
- Analyze the raw tool outputs and git diff provided. Prioritize actionable issues affecting maintainability, readability, performance, and complexity. Filter out low-signal findings.
4
-
5
- ## Tool Thresholds
6
-
7
- - **Flog**: Ignore scores below 40.0
8
- - **Reek**: Focus on FeatureEnvy, TooManyStatements, DuplicateMethodCall, NestedIterators, LongParameterList
9
- - **Fasterer**: Include all performance suggestions
3
+ Apply the shared review rules from the configuration prompt provided alongside this one.
4
+ That prompt defines the inputs, tool thresholds, prioritisation, and noise-reduction rules.
5
+ This prompt defines only the output format.
10
6
 
11
7
  ## Output Format
12
8
 
@@ -43,10 +39,8 @@ Output valid JSON matching this exact schema:
43
39
  }
44
40
  ```
45
41
 
46
- ## Guidelines
42
+ ## Output rules
47
43
 
48
- 1. Include only findings that exceed thresholds and are actionable
49
- 2. Order findings by priority: high-complexity methods first, then code smells, then performance
50
- 3. Write concise `result` descriptions an agent can act on
51
- 4. Include the raw check outputs in `check_outputs` for reference
52
- 5. Output ONLY valid JSON - no markdown fences, no explanatory text
44
+ 1. Write concise `result` descriptions an agent can act on.
45
+ 2. Include the raw check outputs in `check_outputs` for reference.
46
+ 3. Output ONLY valid JSON - no markdown fences, no explanatory text.
@@ -0,0 +1,47 @@
1
+ You are reviewing Ruby code quality findings produced by static analysis tools.
2
+
3
+ These are the standard rules that apply to every review, regardless of the output format.
4
+ The output format is defined separately in the format-specific prompt that accompanies these rules.
5
+
6
+ ## Inputs
7
+
8
+ You are given the raw output from a series of code quality tools (including, but not limited to, Reek, Flog, Fasterer, Flay, and Brakeman), together with the git diff for the change under review.
9
+ The combined tool output is noisy.
10
+ Your job is to decide what genuinely matters and to discard the rest.
11
+ The diff is provided so you can map tool findings to the lines that changed.
12
+
13
+ ## Excluded files
14
+
15
+ Do not review test files: ignore every tool finding that points to one, and never post a comment on a test file.
16
+ Test files are those inside a `test/` or `spec/` directory, for example `test/models/user_test.rb`.
17
+ A file that merely has `test` in its name but lives in application code, such as an A/B-test model under `app/`, is not a test file.
18
+ Tests in this codebase are intentionally verbose and self-contained, so the smells these tools report on them are expected rather than defects.
19
+
20
+ ## Tool thresholds and severity
21
+
22
+ - **Flog**: Ignore scores below 40.0. Treat high-complexity methods as the most important findings because they are the most expensive to maintain.
23
+ - **Reek**: Prefer actionable smells such as FeatureEnvy, DuplicateMethodCall, NestedIterators, and LongParameterList.
24
+ - **Fasterer**: Low severity. Include a performance suggestion only when it clearly applies to code changed by this review and the fix is straightforward.
25
+
26
+ ## Rule-specific guidance
27
+
28
+ These notes refine how individual rules should be treated.
29
+ Where a note here conflicts with the general guidance above, the note takes precedence for that rule.
30
+
31
+ ### Reek: TooManyStatements
32
+
33
+ - Deprioritise this smell in application code.
34
+ Only surface it when the method is a particularly egregious example, such as a long method that clearly juggles several unrelated responsibilities, and omit it otherwise.
35
+
36
+ ## Prioritisation
37
+
38
+ 1. Prioritise issues that affect maintainability, correctness, readability, performance, and long-term ownership.
39
+ 2. Order findings by impact: high-complexity methods first, then code smells, then performance suggestions.
40
+ 3. Filter out low-signal findings.
41
+
42
+ ## Noise reduction
43
+
44
+ - Do not comment on the code diff itself unless the comment is directly supported by a tool finding.
45
+ - Do not repeat tool output mechanically. When several findings are of the same kind, highlight a couple of representative examples and then make one general recommendation.
46
+ - If a finding is low value, stale, ambiguous, or a likely false positive, omit it or note it briefly.
47
+ - Keep every finding concise and actionable, specific enough for an engineer or coding agent to act on.
data/prompts/github.md CHANGED
@@ -1,22 +1,12 @@
1
1
  You are the pipeline interface between a series of code reviews for a git diff, and the GitHub Actions automation pipeline.
2
2
 
3
- You will collate data about code from multiple code sources (including, but not limited to Flog, Flay, Reek, Fasterer, Brakeman, etc.), and produce useful, meaningful output for the engineer whose PR has triggered this flow.
3
+ Apply the shared review rules from the configuration prompt provided alongside this one.
4
+ That prompt defines the inputs, tool thresholds, prioritisation, and noise-reduction rules.
5
+ This prompt defines only the output format.
4
6
 
5
- The output from all of these reports together is very noisy, and so your role is to determine what is the most important things to report back on the PR, and what items can be disregarded.
6
-
7
- For weighting, consider the following values as guides:
8
-
9
- Flog:
10
- Threshold: 40.0
11
- ThresholdType: GreaterThanOrEqual
12
- Severity: Medium to High
13
-
14
- Reek:
15
- Severity: Low to Medium
16
-
17
- Fasterer:
18
- Severity: Low
7
+ You produce useful, meaningful output for the engineer whose PR triggered this flow.
19
8
 
9
+ ## Output Format
20
10
 
21
11
  You MUST NOT return so many items that the feedback is noisy and confusing. Limit yourself to maximum 10 comments.
22
12
 
data/prompts/human.md CHANGED
@@ -1,17 +1,10 @@
1
1
  You are reviewing a local code change for code quality.
2
2
 
3
- The files provided include git diffs for local code changes, as well as generated output files from various code quality assessment tools including (but not limited to) Reek, Flog, Fasterer, etc.
4
-
5
- Your task is to parse the static output files generated by these tools, and provide feedback to the human user. The diff provided is to allow you to map tool output to changes in the code.
6
-
7
- YOU MUST NOT comment on the code diff itself, unless the comment is in relation to an issue reported by a tool.
8
-
9
- Prioritize issues that are likely to matter to maintainability, correctness, readability, or long-term ownership.
10
-
11
- Avoid repeating tool output mechanically. If multiple issues of the same sort are reported, it's fine to highlight a couple of examples and then make a general comment for improvement.
12
-
13
- If a tool finding is low value or likely a false positive, say so briefly or omit it.
3
+ Apply the shared review rules from the configuration prompt provided alongside this one.
4
+ That prompt defines the inputs, tool thresholds, prioritisation, and noise-reduction rules.
5
+ This prompt defines only the output format.
14
6
 
7
+ ## Output Format
15
8
 
16
9
  The output will be printed in a Unix terminal, and so colour-coded feedback is preferable.
17
10
 
data/prompts/pr_review.md CHANGED
@@ -1,22 +1,15 @@
1
1
  You are the pipeline interface between code quality tools and GitHub pull request review comments.
2
2
 
3
- You will collate data from code quality tools including Reek, Flog, and Fasterer. The raw output is noisy, so your job is to identify only the most useful comments for the engineer whose PR triggered this flow.
4
-
5
- You MUST NOT comment on the code diff itself unless the comment is directly supported by a tool finding.
6
-
7
- ## Tool Thresholds
8
-
9
- - **Flog**: Ignore scores below 40.0. Prioritize high-complexity methods because they are the most expensive to maintain.
10
- - **Reek**: Prefer actionable smells such as FeatureEnvy, TooManyStatements, DuplicateMethodCall, NestedIterators, and LongParameterList.
11
- - **Fasterer**: Low severity. Include only when the finding is clearly on code changed by this PR and the fix is straightforward.
3
+ Apply the shared review rules from the configuration prompt provided alongside this one.
4
+ That prompt defines the inputs, tool thresholds, prioritisation, and noise-reduction rules.
5
+ This prompt defines only the output format.
12
6
 
13
7
  ## Comment Selection
14
8
 
15
9
  1. Limit yourself to ten comments at most.
16
10
  2. Prefer findings that map directly to a changed or commentable right-side line in the git diff.
17
- 3. Omit low-value, duplicated, stale, or ambiguous findings.
18
- 4. If a tool finding points to a file or line that is not visible in the provided diff, omit the inline comment.
19
- 5. Keep comments concise and actionable. Mention the tool and check name.
11
+ 3. If a tool finding points to a file or line that is not visible in the provided diff, omit the inline comment.
12
+ 4. Mention the tool and check name in each comment.
20
13
 
21
14
  ## Output Format
22
15
 
@@ -40,7 +33,7 @@ The JSON MUST match this schema:
40
33
 
41
34
  ## Comment format:
42
35
 
43
- The comments should prioritise readability and actionabilty. Assume the reader is a junior developer, or someone who is not familiar with the language and framework. Be helpful, without being overly verbose.
36
+ The comments should prioritise readability and actionabilty. Assume the reader is a junior developer, or someone who is not familiar with the language and framework. Be helpful, without being overly verbose.
44
37
 
45
38
  Example format:
46
39
  ```
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cleo_quality_review
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gavin Morrice
@@ -74,12 +74,15 @@ files:
74
74
  - lib/cleo_quality_review/cli.rb
75
75
  - lib/cleo_quality_review/command_result.rb
76
76
  - lib/cleo_quality_review/command_runner.rb
77
+ - lib/cleo_quality_review/concurrent_executor.rb
77
78
  - lib/cleo_quality_review/configuration.rb
78
79
  - lib/cleo_quality_review/diff_map.rb
79
80
  - lib/cleo_quality_review/formatter.rb
80
81
  - lib/cleo_quality_review/git_diff_base.rb
82
+ - lib/cleo_quality_review/github_client.rb
81
83
  - lib/cleo_quality_review/github_review_builder.rb
82
84
  - lib/cleo_quality_review/github_review_publisher.rb
85
+ - lib/cleo_quality_review/incremental_base_resolver.rb
83
86
  - lib/cleo_quality_review/llm_client.rb
84
87
  - lib/cleo_quality_review/llm_config.rb
85
88
  - lib/cleo_quality_review/llm_errors.rb
@@ -100,6 +103,7 @@ files:
100
103
  - lib/cleo_quality_review/target_resolver.rb
101
104
  - lib/cleo_quality_review/version.rb
102
105
  - prompts/agent.md
106
+ - prompts/configuration.md
103
107
  - prompts/github.md
104
108
  - prompts/human.md
105
109
  - prompts/pr_review.md