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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/cleo_quality_review.gemspec +31 -0
  3. data/config/default.yml +7 -0
  4. data/exe/check_quality +20 -0
  5. data/lib/cleo_quality_review/changes_diff.rb +67 -0
  6. data/lib/cleo_quality_review/checks/debride.rb +65 -0
  7. data/lib/cleo_quality_review/checks/fasterer.rb +35 -0
  8. data/lib/cleo_quality_review/checks/flog.rb +35 -0
  9. data/lib/cleo_quality_review/checks/quality_check.rb +143 -0
  10. data/lib/cleo_quality_review/checks/reek.rb +53 -0
  11. data/lib/cleo_quality_review/checks/registry.rb +72 -0
  12. data/lib/cleo_quality_review/checks.rb +38 -0
  13. data/lib/cleo_quality_review/cli.rb +105 -0
  14. data/lib/cleo_quality_review/command_result.rb +21 -0
  15. data/lib/cleo_quality_review/command_runner.rb +27 -0
  16. data/lib/cleo_quality_review/configuration.rb +193 -0
  17. data/lib/cleo_quality_review/diff_map.rb +95 -0
  18. data/lib/cleo_quality_review/formatter.rb +58 -0
  19. data/lib/cleo_quality_review/github_review_builder.rb +140 -0
  20. data/lib/cleo_quality_review/github_review_publisher.rb +150 -0
  21. data/lib/cleo_quality_review/llm_client.rb +59 -0
  22. data/lib/cleo_quality_review/llm_config.rb +40 -0
  23. data/lib/cleo_quality_review/llm_errors.rb +19 -0
  24. data/lib/cleo_quality_review/llm_logger.rb +66 -0
  25. data/lib/cleo_quality_review/llm_providers/open_ai.rb +188 -0
  26. data/lib/cleo_quality_review/llm_providers/open_ai_config.rb +83 -0
  27. data/lib/cleo_quality_review/llm_providers/registry.rb +61 -0
  28. data/lib/cleo_quality_review/llm_providers/stub.rb +107 -0
  29. data/lib/cleo_quality_review/llm_providers.rb +44 -0
  30. data/lib/cleo_quality_review/options.rb +171 -0
  31. data/lib/cleo_quality_review/prompt_builder.rb +95 -0
  32. data/lib/cleo_quality_review/prompt_loader.rb +49 -0
  33. data/lib/cleo_quality_review/result.rb +58 -0
  34. data/lib/cleo_quality_review/run.rb +78 -0
  35. data/lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb +97 -0
  36. data/lib/cleo_quality_review/run_artifacts.rb +146 -0
  37. data/lib/cleo_quality_review/runner.rb +158 -0
  38. data/lib/cleo_quality_review/target_resolver.rb +127 -0
  39. data/lib/cleo_quality_review/version.rb +7 -0
  40. data/lib/cleo_quality_review.rb +23 -0
  41. data/prompts/agent.md +53 -0
  42. data/prompts/github.md +29 -0
  43. data/prompts/human.md +23 -0
  44. data/prompts/pr_review.md +62 -0
  45. 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