cleo_quality_review 0.2.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/cleo_quality_review.gemspec +0 -1
- data/lib/cleo_quality_review/changes_diff.rb +16 -13
- data/lib/cleo_quality_review/checks.rb +0 -1
- 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 +16 -5
- 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/lib/cleo_quality_review.rb +0 -1
- data/prompts/agent.md +9 -16
- data/prompts/configuration.md +47 -0
- data/prompts/github.md +5 -19
- data/prompts/human.md +5 -13
- data/prompts/pr_review.md +6 -14
- metadata +5 -16
- data/lib/cleo_quality_review/checks/debride.rb +0 -65
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
|
data/cleo_quality_review.gemspec
CHANGED
|
@@ -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
|