cleo_quality_review 0.1.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 +7 -0
- data/cleo_quality_review.gemspec +31 -0
- data/config/default.yml +7 -0
- data/exe/check_quality +20 -0
- data/lib/cleo_quality_review/changes_diff.rb +67 -0
- data/lib/cleo_quality_review/checks/debride.rb +65 -0
- data/lib/cleo_quality_review/checks/fasterer.rb +35 -0
- data/lib/cleo_quality_review/checks/flog.rb +35 -0
- data/lib/cleo_quality_review/checks/quality_check.rb +143 -0
- data/lib/cleo_quality_review/checks/reek.rb +53 -0
- data/lib/cleo_quality_review/checks/registry.rb +72 -0
- data/lib/cleo_quality_review/checks.rb +38 -0
- data/lib/cleo_quality_review/cli.rb +105 -0
- data/lib/cleo_quality_review/command_result.rb +21 -0
- data/lib/cleo_quality_review/command_runner.rb +27 -0
- data/lib/cleo_quality_review/configuration.rb +193 -0
- data/lib/cleo_quality_review/diff_map.rb +95 -0
- data/lib/cleo_quality_review/formatter.rb +58 -0
- data/lib/cleo_quality_review/github_review_builder.rb +140 -0
- data/lib/cleo_quality_review/github_review_publisher.rb +150 -0
- data/lib/cleo_quality_review/llm_client.rb +59 -0
- data/lib/cleo_quality_review/llm_config.rb +40 -0
- data/lib/cleo_quality_review/llm_errors.rb +19 -0
- data/lib/cleo_quality_review/llm_logger.rb +66 -0
- data/lib/cleo_quality_review/llm_providers/open_ai.rb +188 -0
- data/lib/cleo_quality_review/llm_providers/open_ai_config.rb +83 -0
- data/lib/cleo_quality_review/llm_providers/registry.rb +61 -0
- data/lib/cleo_quality_review/llm_providers/stub.rb +107 -0
- data/lib/cleo_quality_review/llm_providers.rb +44 -0
- data/lib/cleo_quality_review/options.rb +171 -0
- data/lib/cleo_quality_review/prompt_builder.rb +95 -0
- data/lib/cleo_quality_review/prompt_loader.rb +49 -0
- data/lib/cleo_quality_review/result.rb +58 -0
- data/lib/cleo_quality_review/run.rb +78 -0
- data/lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb +97 -0
- data/lib/cleo_quality_review/run_artifacts.rb +146 -0
- data/lib/cleo_quality_review/runner.rb +158 -0
- data/lib/cleo_quality_review/target_resolver.rb +127 -0
- data/lib/cleo_quality_review/version.rb +7 -0
- data/lib/cleo_quality_review.rb +23 -0
- data/prompts/agent.md +53 -0
- data/prompts/github.md +29 -0
- data/prompts/human.md +23 -0
- data/prompts/pr_review.md +62 -0
- metadata +141 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CleoQualityReview
|
|
4
|
+
##
|
|
5
|
+
# Value object representing the result of a shell command execution
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] stdout
|
|
8
|
+
# @return [String] standard output from the command
|
|
9
|
+
# @!attribute [r] stderr
|
|
10
|
+
# @return [String] standard error from the command
|
|
11
|
+
# @!attribute [r] status
|
|
12
|
+
# @return [Process::Status] process exit status
|
|
13
|
+
CommandResult = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
|
|
14
|
+
##
|
|
15
|
+
# Check if the command succeeded
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def success?
|
|
18
|
+
status.success?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
require_relative "command_result"
|
|
6
|
+
|
|
7
|
+
module CleoQualityReview
|
|
8
|
+
##
|
|
9
|
+
# Executes shell commands and captures output
|
|
10
|
+
class CommandRunner
|
|
11
|
+
##
|
|
12
|
+
# Run a shell command and capture its output
|
|
13
|
+
# @param [Array<String>] command command and arguments to execute
|
|
14
|
+
# @param [Hash{String => String}] env environment variables
|
|
15
|
+
# @param [String, nil] stdin_data data to pipe to stdin
|
|
16
|
+
# @return [CommandResult]
|
|
17
|
+
def run(*command, env: {}, stdin_data: nil)
|
|
18
|
+
stdout, stderr, status = if stdin_data.nil?
|
|
19
|
+
Open3.capture3(env, *command)
|
|
20
|
+
else
|
|
21
|
+
Open3.capture3(env, *command, stdin_data: stdin_data)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
CommandResult.new(stdout: stdout, stderr: stderr, status: status)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module CleoQualityReview
|
|
7
|
+
##
|
|
8
|
+
# Configuration for file include/exclude patterns
|
|
9
|
+
class Configuration
|
|
10
|
+
DEFAULT_CONFIG_PATH = File.expand_path("../../config/default.yml", __dir__)
|
|
11
|
+
LOCAL_CONFIG_PATH = ".cleo_quality_review.yaml"
|
|
12
|
+
ALL_TOOLS = "AllTools"
|
|
13
|
+
INCLUDE = "Include"
|
|
14
|
+
EXCLUDE = "Exclude"
|
|
15
|
+
INHERIT_FROM = "inherit_from"
|
|
16
|
+
GEM_DEFAULT_ALIASES = ["default", "gem:default"].freeze
|
|
17
|
+
MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# Load configuration from default and local config files
|
|
21
|
+
# @param [String] root root directory for local config lookup
|
|
22
|
+
# @return [Configuration]
|
|
23
|
+
def self.load(root: Dir.pwd)
|
|
24
|
+
Loader.new(root: root).load
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @param [Hash] data parsed configuration data
|
|
29
|
+
def initialize(data)
|
|
30
|
+
@data = data
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @return [Array<String>] glob patterns for files to include
|
|
35
|
+
def include_patterns
|
|
36
|
+
patterns_for(INCLUDE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @return [Array<String>] glob patterns for files to exclude
|
|
41
|
+
def exclude_patterns
|
|
42
|
+
patterns_for(EXCLUDE)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# Check if a file should be included based on configuration patterns
|
|
47
|
+
# @param [String] path file path to check
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def target_file?(path)
|
|
50
|
+
normalized_path = normalize_path(path)
|
|
51
|
+
|
|
52
|
+
matches_any?(include_patterns, normalized_path) && !matches_any?(exclude_patterns, normalized_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :data
|
|
58
|
+
|
|
59
|
+
def patterns_for(key)
|
|
60
|
+
Array(data.fetch(ALL_TOOLS) { {} }.fetch(key) { [] }).map(&:to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def matches_any?(patterns, path)
|
|
64
|
+
patterns.any? { |pattern| matches?(pattern, path) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def matches?(pattern, path)
|
|
68
|
+
normalized_pattern = normalize_pattern(pattern)
|
|
69
|
+
|
|
70
|
+
File.fnmatch?(normalized_pattern, path, MATCH_FLAGS)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_path(path)
|
|
74
|
+
path.to_s.delete_prefix("./").tr(File::ALT_SEPARATOR || File::SEPARATOR, File::SEPARATOR).tr("\\", "/")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_pattern(pattern)
|
|
78
|
+
pattern.to_s.delete_prefix("./").tr("\\", "/")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# Loads and merges configuration files with inheritance support
|
|
83
|
+
class Loader
|
|
84
|
+
##
|
|
85
|
+
# @param [String] root root directory for config file lookup
|
|
86
|
+
def initialize(root:)
|
|
87
|
+
@root = File.expand_path(root)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# Load merged configuration
|
|
92
|
+
# @return [Configuration]
|
|
93
|
+
def load
|
|
94
|
+
data = load_file(DEFAULT_CONFIG_PATH)
|
|
95
|
+
local_config_path = File.join(root, LOCAL_CONFIG_PATH)
|
|
96
|
+
data = merge(data, load_file(local_config_path)) if File.file?(local_config_path)
|
|
97
|
+
|
|
98
|
+
Configuration.new(data)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
attr_reader :root
|
|
104
|
+
|
|
105
|
+
def load_file(path, seen: Set.new)
|
|
106
|
+
expanded_path = expand_config_path(path, relative_to: root)
|
|
107
|
+
return {} if skip_file?(expanded_path, seen)
|
|
108
|
+
|
|
109
|
+
seen.add(expanded_path)
|
|
110
|
+
load_with_inheritance(expanded_path, seen)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def skip_file?(expanded_path, seen)
|
|
114
|
+
return true if seen.include?(expanded_path)
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "Config file not found: #{expanded_path}" unless File.file?(expanded_path)
|
|
117
|
+
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def load_with_inheritance(expanded_path, seen)
|
|
122
|
+
config = read_yaml(expanded_path)
|
|
123
|
+
inherited_data = load_inherited(config, expanded_path, seen)
|
|
124
|
+
merge(inherited_data, config.except(INHERIT_FROM))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def load_inherited(config, expanded_path, seen)
|
|
128
|
+
inherit_from(config).reduce({}) do |merged, inherited_path|
|
|
129
|
+
merge(merged, load_file(resolve_inherited_path(inherited_path, expanded_path), seen: seen))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def read_yaml(path)
|
|
134
|
+
parsed = YAML.safe_load(File.read(path), aliases: true)
|
|
135
|
+
return {} if parsed.nil?
|
|
136
|
+
raise ArgumentError, "Config file must contain a YAML mapping: #{path}" unless parsed.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
stringify_keys(parsed)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def inherit_from(config)
|
|
142
|
+
Array(config.fetch(INHERIT_FROM) { [] })
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def resolve_inherited_path(path, parent_path)
|
|
146
|
+
value = path.to_s
|
|
147
|
+
return DEFAULT_CONFIG_PATH if GEM_DEFAULT_ALIASES.include?(value)
|
|
148
|
+
|
|
149
|
+
expand_config_path(value, relative_to: File.dirname(parent_path))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def expand_config_path(path, relative_to:)
|
|
153
|
+
path_string = path.to_s
|
|
154
|
+
return File.expand_path(path_string) if path_string.start_with?("/", "~")
|
|
155
|
+
|
|
156
|
+
File.expand_path(path_string, relative_to)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def merge(base, override)
|
|
160
|
+
base.merge(override) do |_key, base_value, override_value|
|
|
161
|
+
merge_values(base_value, override_value)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def merge_values(base_value, override_value)
|
|
166
|
+
return merge(base_value, override_value) if both_hashes?(base_value, override_value)
|
|
167
|
+
return (base_value + override_value).uniq if both_arrays?(base_value, override_value)
|
|
168
|
+
|
|
169
|
+
override_value
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def both_hashes?(a, b) = a.is_a?(Hash) && b.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
def both_arrays?(a, b) = a.is_a?(Array) && b.is_a?(Array)
|
|
175
|
+
|
|
176
|
+
def stringify_keys(value)
|
|
177
|
+
case value
|
|
178
|
+
when Hash then stringify_hash_keys(value)
|
|
179
|
+
when Array then stringify_array_values(value)
|
|
180
|
+
else value
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def stringify_hash_keys(hash)
|
|
185
|
+
hash.to_h { |key, v| [key.to_s, stringify_keys(v)] }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def stringify_array_values(array)
|
|
189
|
+
array.map { |v| stringify_keys(v) }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module CleoQualityReview
|
|
6
|
+
##
|
|
7
|
+
# Maps a unified git diff to right-side line numbers that GitHub can comment on
|
|
8
|
+
class DiffMap
|
|
9
|
+
HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(?<line>\d+)(?:,\d+)? @@/.freeze
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Stateful parser for file and right-side hunk line transitions
|
|
13
|
+
class DiffParser
|
|
14
|
+
def initialize(commentable_lines)
|
|
15
|
+
@commentable_lines = commentable_lines
|
|
16
|
+
@path = nil
|
|
17
|
+
@new_line = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse(diff)
|
|
21
|
+
diff.each_line { |line| parse_line(line) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :commentable_lines, :new_line, :path
|
|
27
|
+
|
|
28
|
+
def parse_line(line)
|
|
29
|
+
if line.start_with?("+++ ")
|
|
30
|
+
start_file(line)
|
|
31
|
+
elsif (line_number = hunk_start_line(line))
|
|
32
|
+
@new_line = line_number
|
|
33
|
+
elsif in_hunk?
|
|
34
|
+
parse_hunk_line(line)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def start_file(line)
|
|
39
|
+
@path = normalize_path(line.delete_prefix("+++ ").strip)
|
|
40
|
+
@new_line = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def hunk_start_line(line)
|
|
44
|
+
match = line.match(HUNK_HEADER)
|
|
45
|
+
match[:line].to_i if match
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def in_hunk?
|
|
49
|
+
path && new_line
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_hunk_line(line)
|
|
53
|
+
case line[0]
|
|
54
|
+
when "+", " "
|
|
55
|
+
commentable_lines[path] << new_line
|
|
56
|
+
@new_line += 1
|
|
57
|
+
when "-"
|
|
58
|
+
new_line
|
|
59
|
+
else
|
|
60
|
+
@new_line = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_path(path)
|
|
65
|
+
return nil if path == "/dev/null"
|
|
66
|
+
|
|
67
|
+
path.delete_prefix("b/")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# @param [String] diff unified git diff content
|
|
73
|
+
def initialize(diff)
|
|
74
|
+
@diff = diff.to_s
|
|
75
|
+
@commentable_lines = Hash.new { |hash, key| hash[key] = Set.new }
|
|
76
|
+
parse
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# @param [String] filepath repository-relative file path
|
|
81
|
+
# @param [Integer] line right-side line number
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
def commentable?(filepath, line)
|
|
84
|
+
commentable_lines[filepath.to_s].include?(line.to_i)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
attr_reader :commentable_lines, :diff
|
|
90
|
+
|
|
91
|
+
def parse
|
|
92
|
+
DiffParser.new(commentable_lines).parse(diff)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_client"
|
|
4
|
+
require_relative "llm_config"
|
|
5
|
+
require_relative "prompt_builder"
|
|
6
|
+
require_relative "prompt_loader"
|
|
7
|
+
require_relative "run_artifacts"
|
|
8
|
+
|
|
9
|
+
module CleoQualityReview
|
|
10
|
+
##
|
|
11
|
+
# Formats quality review results using an LLM with format-specific prompts
|
|
12
|
+
class Formatter
|
|
13
|
+
##
|
|
14
|
+
# @param [Run] run the quality review run to format
|
|
15
|
+
# @param [CommandRunner] command_runner for executing shell commands
|
|
16
|
+
# @param [LlmConfig] llm_config LLM provider configuration
|
|
17
|
+
# @param [LlmClient, nil] llm_client optional pre-configured client
|
|
18
|
+
def initialize(run:, command_runner:, llm_config: LlmConfig.new, llm_client: nil)
|
|
19
|
+
@run = run
|
|
20
|
+
@command_runner = command_runner
|
|
21
|
+
@llm_config = llm_config
|
|
22
|
+
@llm_client = llm_client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Format the run by generating an LLM review
|
|
27
|
+
# @return [String] formatted review text
|
|
28
|
+
def format
|
|
29
|
+
llm_client.generate_review(prompt)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :run, :command_runner, :llm_config
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @return [String]
|
|
38
|
+
def prompt
|
|
39
|
+
PromptBuilder.new(
|
|
40
|
+
run: run,
|
|
41
|
+
prompt: PromptLoader.load(format: run.format),
|
|
42
|
+
artifacts: artifacts,
|
|
43
|
+
).build
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# @return [RunArtifacts]
|
|
48
|
+
def artifacts
|
|
49
|
+
@artifacts ||= run.artifacts || RunArtifacts.load(review_id: run.review_id || run.timestamp)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# @return [LlmClient]
|
|
54
|
+
def llm_client
|
|
55
|
+
@llm_client ||= LlmClient.new(config: llm_config, log: run.log)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "diff_map"
|
|
6
|
+
require_relative "llm_errors"
|
|
7
|
+
|
|
8
|
+
module CleoQualityReview
|
|
9
|
+
##
|
|
10
|
+
# Builds a GitHub pull request review payload from rendered pr_review JSON
|
|
11
|
+
class GitHubReviewBuilder
|
|
12
|
+
MAX_INLINE_COMMENTS = 20
|
|
13
|
+
MAX_BODY_LENGTH = 3_500
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Normalized rendered comment that can be mapped onto a PR diff line
|
|
17
|
+
InlineComment = Struct.new(:path, :line, :body, keyword_init: true) do
|
|
18
|
+
def valid?
|
|
19
|
+
path != "" && line.positive? && body != ""
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def commentable_on?(diff_map)
|
|
23
|
+
valid? && diff_map.commentable?(path, line)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_review_payload(diff_map:, truncator:)
|
|
27
|
+
return unless commentable_on?(diff_map)
|
|
28
|
+
|
|
29
|
+
{ path: path, line: line, side: "RIGHT", body: truncator.call(body) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @param [Run] run completed quality review run
|
|
35
|
+
# @param [String] rendered_review JSON produced by the pr_review formatter
|
|
36
|
+
def initialize(run:, rendered_review:)
|
|
37
|
+
@run = run
|
|
38
|
+
@rendered_review = rendered_review
|
|
39
|
+
@diff_map = DiffMap.new(run.artifacts.changes_diff)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param [String, nil] commit_id pull request head SHA
|
|
44
|
+
# @return [Hash] GitHub pull request review payload
|
|
45
|
+
def payload(commit_id: nil)
|
|
46
|
+
comments = inline_comments
|
|
47
|
+
payload = {
|
|
48
|
+
event: "COMMENT",
|
|
49
|
+
body: review_body(comments),
|
|
50
|
+
}
|
|
51
|
+
payload[:commit_id] = commit_id if commit_id.to_s.strip != ""
|
|
52
|
+
payload[:comments] = comments unless comments.empty?
|
|
53
|
+
payload
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @return [String] hidden marker used to avoid duplicate reviews
|
|
58
|
+
def marker
|
|
59
|
+
"<!-- cleo-quality-review:#{run.review_id} -->"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# @return [Boolean] whether the rendered review contains anything useful to publish
|
|
64
|
+
def empty?
|
|
65
|
+
rendered_comments.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :diff_map, :rendered_review, :run
|
|
71
|
+
|
|
72
|
+
def inline_comments
|
|
73
|
+
rendered_comments.first(MAX_INLINE_COMMENTS).filter_map do |comment|
|
|
74
|
+
inline_comment_payload(normalized_comment(comment))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalized_comment(comment)
|
|
79
|
+
InlineComment.new(
|
|
80
|
+
path: comment["path"].to_s,
|
|
81
|
+
line: comment["line"].to_i,
|
|
82
|
+
body: comment["body"].to_s.strip,
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inline_comment_payload(comment)
|
|
87
|
+
comment.to_review_payload(diff_map: diff_map, truncator: method(:truncate))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def rendered_comments
|
|
91
|
+
comments = parsed_review.fetch("comments", [])
|
|
92
|
+
raise Error, "pr_review JSON field \"comments\" must be an array" unless comments.is_a?(Array)
|
|
93
|
+
|
|
94
|
+
comments
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parsed_review
|
|
98
|
+
@parsed_review ||= begin
|
|
99
|
+
parsed = JSON.parse(rendered_review.to_s)
|
|
100
|
+
raise Error, "pr_review JSON must be an object" unless parsed.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
parsed
|
|
103
|
+
end
|
|
104
|
+
rescue JSON::ParserError => e
|
|
105
|
+
raise Error, "pr_review output was not valid JSON: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def review_body(comments)
|
|
109
|
+
[
|
|
110
|
+
marker,
|
|
111
|
+
body_text,
|
|
112
|
+
inline_summary(comments),
|
|
113
|
+
].compact.join("\n\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def body_text
|
|
117
|
+
parsed_review.fetch("body", "").to_s.strip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def inline_summary(comments)
|
|
121
|
+
published_count = comments.length
|
|
122
|
+
requested_count = rendered_comments.length
|
|
123
|
+
omitted_comments_message(published_count, requested_count)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def omitted_comments_message(published, requested)
|
|
127
|
+
return "No rendered comments mapped to commentable PR diff lines." if published.zero? && requested.positive?
|
|
128
|
+
return if published == requested
|
|
129
|
+
|
|
130
|
+
omitted = requested - published
|
|
131
|
+
"#{omitted} rendered comment#{'s' unless omitted == 1} were omitted because they did not map to commentable PR diff lines."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def truncate(value)
|
|
135
|
+
return value if value.length <= MAX_BODY_LENGTH
|
|
136
|
+
|
|
137
|
+
"#{value[0, MAX_BODY_LENGTH - 20]}\n\n[truncated]"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "github_review_builder"
|
|
8
|
+
require_relative "llm_errors"
|
|
9
|
+
|
|
10
|
+
module CleoQualityReview
|
|
11
|
+
##
|
|
12
|
+
# Publishes quality review findings as a GitHub pull request review
|
|
13
|
+
class GitHubReviewPublisher
|
|
14
|
+
API_VERSION = "2022-11-28"
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# @param [Run] run completed quality review run
|
|
18
|
+
# @param [String] rendered_review JSON produced by the pr_review formatter
|
|
19
|
+
# @param [Hash{String => String}] env process environment
|
|
20
|
+
def initialize(run:, rendered_review:, env: ENV)
|
|
21
|
+
@run = run
|
|
22
|
+
@rendered_review = rendered_review
|
|
23
|
+
@env = env
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Publish the review, or skip when there is no PR context/findings
|
|
28
|
+
# @return [String] status message
|
|
29
|
+
def publish
|
|
30
|
+
skip_reason = publication_skip_reason
|
|
31
|
+
return skip_reason if skip_reason
|
|
32
|
+
|
|
33
|
+
post_review
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def publication_skip_reason
|
|
39
|
+
review_id = run.review_id
|
|
40
|
+
return "No PR review comments to publish." if builder.empty?
|
|
41
|
+
return "No pull_request event found; skipping PR review publication." unless pull_request_context?
|
|
42
|
+
return "PR review already published for review ID #{review_id}; skipping." if already_published?
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def post_review
|
|
48
|
+
response = request_json(:post, reviews_uri, builder.payload(commit_id: head_sha))
|
|
49
|
+
raise Error, "GitHub PR review publication failed with status #{response.status_code}: #{response.body}" unless response.success?
|
|
50
|
+
|
|
51
|
+
"Published PR review for review ID #{run.review_id}."
|
|
52
|
+
end
|
|
53
|
+
|
|
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
|
+
attr_reader :env, :rendered_review, :run
|
|
61
|
+
|
|
62
|
+
def already_published?
|
|
63
|
+
response = request_json(:get, reviews_uri)
|
|
64
|
+
body = response.body
|
|
65
|
+
raise Error, "GitHub PR review lookup failed with status #{response.status_code}: #{body}" unless response.success?
|
|
66
|
+
|
|
67
|
+
JSON.parse(body).any? do |review|
|
|
68
|
+
review.fetch("body", "").include?(builder.marker)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def builder
|
|
73
|
+
@builder ||= GitHubReviewBuilder.new(run: run, rendered_review: rendered_review)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def pull_request_context?
|
|
77
|
+
event.fetch("pull_request", nil).is_a?(Hash)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reviews_uri
|
|
81
|
+
URI("#{api_url}/repos/#{repository}/pulls/#{pull_request_number}/reviews")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pull_request_number
|
|
85
|
+
event["number"] || event.fetch("pull_request").fetch("number")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def head_sha
|
|
89
|
+
event.fetch("pull_request").fetch("head").fetch("sha")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def repository
|
|
93
|
+
env.fetch("GITHUB_REPOSITORY")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def api_url
|
|
97
|
+
env.fetch("GITHUB_API_URL", "https://api.github.com")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def event
|
|
101
|
+
@event ||= JSON.parse(File.read(env.fetch("GITHUB_EVENT_PATH")))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def token
|
|
105
|
+
env.fetch("GITHUB_TOKEN")
|
|
106
|
+
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
|
+
end
|
|
150
|
+
end
|