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 +4 -4
- data/lib/cleo_quality_review/changes_diff.rb +16 -13
- data/lib/cleo_quality_review/cli.rb +11 -2
- data/lib/cleo_quality_review/concurrent_executor.rb +125 -0
- data/lib/cleo_quality_review/configuration.rb +32 -1
- data/lib/cleo_quality_review/formatter.rb +18 -3
- data/lib/cleo_quality_review/github_client.rb +104 -0
- data/lib/cleo_quality_review/github_review_builder.rb +13 -5
- data/lib/cleo_quality_review/github_review_publisher.rb +12 -57
- data/lib/cleo_quality_review/incremental_base_resolver.rb +157 -0
- data/lib/cleo_quality_review/llm_client.rb +9 -5
- data/lib/cleo_quality_review/llm_providers/open_ai.rb +15 -7
- data/lib/cleo_quality_review/llm_providers/stub.rb +7 -3
- data/lib/cleo_quality_review/options.rb +14 -3
- data/lib/cleo_quality_review/run.rb +8 -0
- data/lib/cleo_quality_review/runner.rb +33 -9
- data/lib/cleo_quality_review/version.rb +1 -1
- data/prompts/agent.md +7 -13
- data/prompts/configuration.md +47 -0
- data/prompts/github.md +5 -15
- data/prompts/human.md +4 -11
- data/prompts/pr_review.md +6 -13
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9277c83af1fccd6045fde0ef6879b6fa7d9d2fc0d1848e1401d58f1889a6c458
|
|
4
|
+
data.tar.gz: c2d158fd19d8566edad3556ed46b020c34ec2a2c7ade2199d3b06b2c612b1e6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 ||=
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
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 ||=
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
81
|
-
|
|
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
|
|
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(
|
|
106
|
+
def execute_request(body)
|
|
105
107
|
timeout_seconds = config.timeout_seconds
|
|
106
|
-
http_transport.post_json(build_request(
|
|
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(
|
|
121
|
+
def build_request(body, timeout_seconds)
|
|
120
122
|
HttpRequest.new(
|
|
121
123
|
uri: RESPONSES_API_URL,
|
|
122
124
|
headers: headers,
|
|
123
|
-
body:
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
38
|
-
|
|
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
|
-
@
|
|
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, :
|
|
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
|
-
|
|
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
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
##
|
|
42
|
+
## Output rules
|
|
47
43
|
|
|
48
|
-
1.
|
|
49
|
-
2.
|
|
50
|
-
3.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
18
|
-
4.
|
|
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.
|
|
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
|