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