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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ef174aa218590b9a2f32114c94e9ef21c73d6809e87524750d66adc70562f0b
4
- data.tar.gz: d25eae8497f8b87506231b4586614729b58b93634c3ed37a935d5029abcd3056
3
+ metadata.gz: 9277c83af1fccd6045fde0ef6879b6fa7d9d2fc0d1848e1401d58f1889a6c458
4
+ data.tar.gz: c2d158fd19d8566edad3556ed46b020c34ec2a2c7ade2199d3b06b2c612b1e6c
5
5
  SHA512:
6
- metadata.gz: a3c8d5bf0d7748a797b7c82c74ff5f1e914147b16b45b13e342e49268eb5e355dcb9885953725c16f118254ff48c728782289fcf291c49478ca0aca002f071bd
7
- data.tar.gz: 436193715042ed3165c0ecf04026a70e0681a96bbd5bb6adc9c842662ae405e8b5409b408c5597c21884590374b1029ef94c3230bded8667117c978511d40ce9
6
+ metadata.gz: 6fbd671e9068c52268bf5f2d139817a330919c630ebe59773e785a2e25f5fa8039eb34280ad0b58cb31de984404ee8b59ab5deb4469d9246a60f59f088ccbb6d
7
+ data.tar.gz: 37731db93423a4da4948a00ab7550809d6f9a1dabc69cb313e2ba066e6239fe0160b3a12c560da66976ecf324d344eee5398cd95b0b1778b4ea07441d46620f1
@@ -24,7 +24,6 @@ Gem::Specification.new do |spec|
24
24
  spec.executables = ["check_quality"]
25
25
  spec.require_paths = ["lib"]
26
26
 
27
- spec.add_dependency "debride"
28
27
  spec.add_dependency "fasterer"
29
28
  spec.add_dependency "flog"
30
29
  spec.add_dependency "reek"
@@ -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 ||= [tracked_changes_diff, untracked_changes_diff].reject(&:empty?).join("\n")
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
- def tracked_changes_diff
40
- command = ["git", "diff", diff_base]
41
- command.concat(["--", *target_files]) unless target_files.empty?
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
- command_runner.run(*command).stdout
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
- command = ["git", "ls-files", "--others", "--exclude-standard"]
54
- empty_targets = target_files.empty?
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
@@ -9,7 +9,6 @@ module CleoQualityReview
9
9
  require_relative "checks/reek"
10
10
  require_relative "checks/flog"
11
11
  require_relative "checks/fasterer"
12
- require_relative "checks/debride"
13
12
 
14
13
  class << self
15
14
  ##
@@ -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 = Runner.new(options: options, command_runner: command_runner).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 = Runner.new(options: options, command_runner: command_runner).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
- # @return [String] formatted review text
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
- llm_client.generate_review(prompt)
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 ||= begin
99
- parsed = JSON.parse(rendered_review.to_s)
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
- parsed
103
- end
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
- def initialize(run:, rendered_review:, env: ENV)
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 = request_json(:post, reviews_uri, builder.payload(commit_id: head_sha))
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 = request_json(:get, reviews_uri)
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 reviews_uri
81
- URI("#{api_url}/repos/#{repository}/pulls/#{pull_request_number}/reviews")
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