cleo_quality_review 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9277c83af1fccd6045fde0ef6879b6fa7d9d2fc0d1848e1401d58f1889a6c458
4
- data.tar.gz: c2d158fd19d8566edad3556ed46b020c34ec2a2c7ade2199d3b06b2c612b1e6c
3
+ metadata.gz: 85099120967159c27f15cfa154de5e081e125b08c6c2e3e910081d392caafa04
4
+ data.tar.gz: 7b7e772fa82cabf8ef0add3a6c39af9c16a0a02d95648f1060716b750eac9ac1
5
5
  SHA512:
6
- metadata.gz: 6fbd671e9068c52268bf5f2d139817a330919c630ebe59773e785a2e25f5fa8039eb34280ad0b58cb31de984404ee8b59ab5deb4469d9246a60f59f088ccbb6d
7
- data.tar.gz: 37731db93423a4da4948a00ab7550809d6f9a1dabc69cb313e2ba066e6239fe0160b3a12c560da66976ecf324d344eee5398cd95b0b1778b4ea07441d46620f1
6
+ metadata.gz: '01243971003bf84478a7b7b73b2c12d6870bda49717e41b6d91333f83b3f0adce57dff6d646b0610734313f197375321bd8d788e744c7b04bae290dfd489ded1'
7
+ data.tar.gz: 7b839841dff2747bd7560b724fa06bbcb8e904b6577bfe25535e0351f2365d0a6b6787d2e116bee9528fdbba74e7047ee592241d22a34ed53b1e9089fbf0462d
@@ -5,7 +5,7 @@ require "optparse"
5
5
  require_relative "../cleo_quality_review"
6
6
  require_relative "command_runner"
7
7
  require_relative "formatter"
8
- require_relative "github_review_publisher"
8
+ require_relative "sticky_comment_publisher"
9
9
  require_relative "incremental_base_resolver"
10
10
  require_relative "options"
11
11
  require_relative "runner"
@@ -95,7 +95,7 @@ module CleoQualityReview
95
95
  def run_publish_pr_review(arguments)
96
96
  options = Options.parse(arguments)
97
97
  run = RunArtifacts.load(review_id: options.validated_review_id).to_run(**options.run_loading_params)
98
- output = GitHubReviewPublisher.new(run: run, rendered_review: rendered_pr_review(options, run)).publish
98
+ output = StickyCommentPublisher.new(run: run, rendered_review: rendered_pr_review(options, run)).publish
99
99
  print_output(output)
100
100
  0
101
101
  end
@@ -51,6 +51,15 @@ module CleoQualityReview
51
51
  request_json(:post, uri_for(path), body)
52
52
  end
53
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
+
54
63
  private
55
64
 
56
65
  attr_reader :token, :api_url
@@ -74,6 +83,7 @@ module CleoQualityReview
74
83
  {
75
84
  get: Net::HTTP::Get,
76
85
  post: Net::HTTP::Post,
86
+ patch: Net::HTTP::Patch,
77
87
  }.fetch(method) { raise ArgumentError, "Unsupported HTTP method #{method.inspect}" }
78
88
  end
79
89
 
@@ -4,23 +4,25 @@ require "json"
4
4
 
5
5
  require_relative "github_client"
6
6
  require_relative "llm_errors"
7
+ require_relative "sticky_comment_builder"
7
8
 
8
9
  module CleoQualityReview
9
10
  ##
10
11
  # Resolves the git base for an incremental review.
11
12
  #
12
13
  # On a pull request that cleo-quality-review has already reviewed, this
13
- # returns the most recent previously-reviewed commit that is still an
14
- # ancestor of the current head, so only changes made since that review are
15
- # analysed. It falls back to +nil+ (meaning "review the full diff") outside a
16
- # pull request context, when no prior review survives in history, or on any
17
- # lookup error.
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.
18
19
  class IncrementalBaseResolver
19
- REVIEW_MARKER_PREFIX = "<!-- cleo-quality-review:"
20
+ MARKER_PREFIX = StickyCommentBuilder::MARKER_PREFIX
21
+ COMMIT_PATTERN = /#{Regexp.escape(MARKER_PREFIX)}\s*commit=(\S+)\s*-->/
20
22
  DISABLED_VALUES = %w[0 false no off].freeze
21
23
  ENABLED_ENV_KEY = "CLEO_QUALITY_REVIEW_INCREMENTAL"
22
- REVIEWS_PER_PAGE = 100
23
- MAX_REVIEW_PAGES = 20
24
+ COMMENTS_PER_PAGE = 100
25
+ MAX_COMMENT_PAGES = 20
24
26
 
25
27
  ##
26
28
  # @param [CommandRunner] command_runner for executing git commands
@@ -39,7 +41,7 @@ module CleoQualityReview
39
41
  def resolve(head: "HEAD")
40
42
  return nil unless incremental_lookup_available?
41
43
 
42
- newest_reviewed_ancestor(head)
44
+ reviewed_commit(head)
43
45
  rescue StandardError => error
44
46
  warn("cleo-quality-review: incremental base lookup failed (#{error.message}); reviewing the full diff")
45
47
  nil
@@ -55,56 +57,50 @@ module CleoQualityReview
55
57
  enabled? && !pull_request_number.nil? && !token.nil? && !repository.nil?
56
58
  end
57
59
 
58
- def newest_reviewed_ancestor(head)
59
- reviewed_commit_ids.find { |sha| ancestor?(sha, head) }
60
+ def reviewed_commit(head)
61
+ sha = sticky_comment_commit_sha
62
+ sha if sha && ancestor?(sha, head)
60
63
  end
61
64
 
62
- def reviewed_commit_ids
63
- reviews
64
- .select { |review| quality_review?(review) }
65
- .sort_by { |review| review["submitted_at"].to_s }
66
- .reverse
67
- .filter_map { |review| review["commit_id"] }
68
- .reject { |sha| sha.to_s.strip.empty? }
69
- .uniq
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]
70
68
  end
71
69
 
72
70
  ##
73
- # Fetch every submitted review, following pagination so the newest reviews
74
- # are not missed on pull requests with more than one page of reviews.
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.
75
73
  # @return [Array<Hash>]
76
- def reviews
77
- (1..MAX_REVIEW_PAGES).each_with_object([]) do |page, all|
78
- page_reviews = reviews_page(page)
79
- all.concat(page_reviews)
80
- break all if page_reviews.length < REVIEWS_PER_PAGE
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
81
79
  end
82
80
  end
83
81
 
84
- def reviews_page(page)
85
- response = client.get("/repos/#{repository}/pulls/#{pull_request_number}/reviews?per_page=#{REVIEWS_PER_PAGE}&page=#{page}")
86
- raise Error, "GitHub review lookup returned status #{response.status_code}" unless response.success?
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?
87
85
 
88
86
  parsed = JSON.parse(response.body)
89
87
  parsed.is_a?(Array) ? parsed : []
90
88
  end
91
89
 
92
90
  ##
93
- # Only trust bot-authored reviews that carry our marker. A human contributor
94
- # could otherwise forge the marker in their own review and steer the base
95
- # past changes the tool never analysed.
96
- # @param [Hash] review
91
+ # Only trust a bot-authored comment that carries our marker.
92
+ # @param [Hash] comment
97
93
  # @return [Boolean]
98
- def quality_review?(review)
99
- bot_authored?(review) && marked?(review)
94
+ def quality_review?(comment)
95
+ bot_authored?(comment) && marked?(comment)
100
96
  end
101
97
 
102
- def bot_authored?(review)
103
- review.dig("user", "type") == "Bot"
98
+ def bot_authored?(comment)
99
+ comment.dig("user", "type") == "Bot"
104
100
  end
105
101
 
106
- def marked?(review)
107
- review.fetch("body") { "" }.to_s.include?(REVIEW_MARKER_PREFIX)
102
+ def marked?(comment)
103
+ comment.fetch("body") { "" }.to_s.include?(MARKER_PREFIX)
108
104
  end
109
105
 
110
106
  def ancestor?(sha, head)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "llm_errors"
6
+
7
+ module CleoQualityReview
8
+ ##
9
+ # Builds the marker and body for the sticky pull request comment from
10
+ # rendered pr_review JSON
11
+ class StickyCommentBuilder
12
+ MARKER_PREFIX = "<!-- cleo-quality-review:"
13
+ MAX_BODY_LENGTH = 3_500
14
+ CLEAN_MESSAGE = "Cleo quality review found no high-confidence issues worth flagging on this change."
15
+
16
+ ##
17
+ # @param [String] rendered_review JSON produced by the pr_review formatter
18
+ def initialize(rendered_review:)
19
+ @rendered_review = rendered_review
20
+ end
21
+
22
+ ##
23
+ # @param [String] commit_sha head commit reviewed to embed in the marker
24
+ # @return [String] full comment body, including the hidden marker
25
+ def comment_body(commit_sha:)
26
+ [marker(commit_sha), truncate(display_body)].join("\n\n")
27
+ end
28
+
29
+ ##
30
+ # @return [Boolean] whether the rendered review has anything worth publishing
31
+ def empty?
32
+ body_text.empty?
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :rendered_review
38
+
39
+ def marker(commit_sha)
40
+ "#{MARKER_PREFIX} commit=#{commit_sha} -->"
41
+ end
42
+
43
+ def display_body
44
+ body_text.empty? ? CLEAN_MESSAGE : body_text
45
+ end
46
+
47
+ def body_text
48
+ parsed_review.fetch("body", "").to_s.strip
49
+ end
50
+
51
+ def parsed_review
52
+ @parsed_review ||= parse_rendered_review
53
+ end
54
+
55
+ # A blank rendered review (e.g. the render step produced no output because
56
+ # there were no reviewable changes) means there is nothing to publish, so
57
+ # treat it as an empty review rather than failing to parse it as JSON.
58
+ def parse_rendered_review
59
+ content = rendered_review.to_s.strip
60
+ return {} if content.empty?
61
+
62
+ parsed = JSON.parse(content)
63
+ raise Error, "pr_review JSON must be an object" unless parsed.is_a?(Hash)
64
+
65
+ parsed
66
+ rescue JSON::ParserError => e
67
+ raise Error, "pr_review output was not valid JSON: #{e.message}"
68
+ end
69
+
70
+ def truncate(value)
71
+ return value if value.length <= MAX_BODY_LENGTH
72
+
73
+ "#{value[0, MAX_BODY_LENGTH - 20]}\n\n[truncated]"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,143 @@
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
+ # Publishes quality review findings as a single sticky pull request
12
+ # comment, editing it in place on every run rather than posting a new one
13
+ class StickyCommentPublisher
14
+ COMMENTS_PER_PAGE = 100
15
+ MAX_COMMENT_PAGES = 20
16
+
17
+ ##
18
+ # @param [Run] run completed quality review run
19
+ # @param [String] rendered_review JSON produced by the pr_review formatter
20
+ # @param [Hash{String => String}] env process environment
21
+ # @param [GitHubClient, nil] client GitHub API client (built from env when omitted)
22
+ def initialize(run:, rendered_review:, env: ENV, client: nil)
23
+ @run = run
24
+ @env = env
25
+ @client = client
26
+ @builder = StickyCommentBuilder.new(rendered_review: rendered_review)
27
+ end
28
+
29
+ ##
30
+ # Publish the sticky comment, or skip when there is no PR context/findings
31
+ # @return [String] status message
32
+ def publish
33
+ skip_reason = publication_skip_reason
34
+ return skip_reason if skip_reason
35
+
36
+ existing_comment_id ? update_comment : create_comment
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :env, :run, :builder
42
+
43
+ def publication_skip_reason
44
+ return "No pull_request event found; skipping sticky comment publication." unless pull_request_context?
45
+ return "No sticky comment to publish for review ID #{run.review_id}." if builder.empty? && existing_comment_id.nil?
46
+
47
+ nil
48
+ end
49
+
50
+ def create_comment
51
+ response = client.post(comments_path, { body: comment_body })
52
+ raise Error, "GitHub sticky comment creation failed with status #{response.status_code}: #{response.body}" unless response.success?
53
+
54
+ "Created sticky comment for review ID #{run.review_id}."
55
+ end
56
+
57
+ def update_comment
58
+ response = client.patch(comment_path(existing_comment_id), { body: comment_body })
59
+ raise Error, "GitHub sticky comment update failed with status #{response.status_code}: #{response.body}" unless response.success?
60
+
61
+ "Updated sticky comment for review ID #{run.review_id}."
62
+ end
63
+
64
+ def comment_body
65
+ builder.comment_body(commit_sha: head_sha)
66
+ end
67
+
68
+ def existing_comment_id
69
+ return @existing_comment_id if defined?(@existing_comment_id)
70
+
71
+ @existing_comment_id = find_existing_comment_id
72
+ end
73
+
74
+ def find_existing_comment_id
75
+ marked = comments.find { |comment| bot_authored?(comment) && marked?(comment) }
76
+ marked && marked.fetch("id")
77
+ end
78
+
79
+ def comments
80
+ (1..MAX_COMMENT_PAGES).each_with_object([]) do |page, all|
81
+ page_comments = comments_page(page)
82
+ all.concat(page_comments)
83
+ break all if page_comments.length < COMMENTS_PER_PAGE
84
+ end
85
+ end
86
+
87
+ def comments_page(page)
88
+ response = client.get("#{comments_path}?per_page=#{COMMENTS_PER_PAGE}&page=#{page}")
89
+ raise Error, "GitHub comment lookup failed with status #{response.status_code}: #{response.body}" unless response.success?
90
+
91
+ parsed = JSON.parse(response.body)
92
+ parsed.is_a?(Array) ? parsed : []
93
+ end
94
+
95
+ def bot_authored?(comment)
96
+ comment.dig("user", "type") == "Bot"
97
+ end
98
+
99
+ def marked?(comment)
100
+ comment.fetch("body") { "" }.to_s.include?(StickyCommentBuilder::MARKER_PREFIX)
101
+ end
102
+
103
+ def client
104
+ @client ||= GitHubClient.new(token: token, api_url: api_url)
105
+ end
106
+
107
+ def pull_request_context?
108
+ event.fetch("pull_request", nil).is_a?(Hash)
109
+ end
110
+
111
+ def comments_path
112
+ "/repos/#{repository}/issues/#{pull_request_number}/comments"
113
+ end
114
+
115
+ def comment_path(comment_id)
116
+ "/repos/#{repository}/issues/comments/#{comment_id}"
117
+ end
118
+
119
+ def pull_request_number
120
+ event["number"] || event.fetch("pull_request").fetch("number")
121
+ end
122
+
123
+ def head_sha
124
+ event.fetch("pull_request").fetch("head").fetch("sha")
125
+ end
126
+
127
+ def repository
128
+ env.fetch("GITHUB_REPOSITORY")
129
+ end
130
+
131
+ def api_url
132
+ env.fetch("GITHUB_API_URL", "https://api.github.com")
133
+ end
134
+
135
+ def event
136
+ @event ||= JSON.parse(File.read(env.fetch("GITHUB_EVENT_PATH")))
137
+ end
138
+
139
+ def token
140
+ env.fetch("GITHUB_TOKEN")
141
+ end
142
+ end
143
+ end
@@ -3,5 +3,5 @@
3
3
  module CleoQualityReview
4
4
  ##
5
5
  # Gem version
6
- VERSION = "0.4.0"
6
+ VERSION = "0.5.0"
7
7
  end
data/prompts/pr_review.md CHANGED
@@ -1,54 +1,62 @@
1
- You are the pipeline interface between code quality tools and GitHub pull request review comments.
1
+ You are the pipeline interface between code quality tools and a single, standing summary comment on a GitHub pull request.
2
2
 
3
3
  Apply the shared review rules from the configuration prompt provided alongside this one.
4
4
  That prompt defines the inputs, tool thresholds, prioritisation, and noise-reduction rules.
5
5
  This prompt defines only the output format.
6
6
 
7
- ## Comment Selection
7
+ This comment is edited in place on every push rather than replaced or added to, and the same PR also receives GitHub Actions annotations pointing at the exact file and line of each finding.
8
+ Do not attempt to recreate that per-line detail here.
9
+ Write a short, consolidated narrative of the main themes across findings, then point the reader at the annotations for specifics.
8
10
 
9
- 1. Limit yourself to ten comments at most.
10
- 2. Prefer findings that map directly to a changed or commentable right-side line in the git diff.
11
- 3. If a tool finding points to a file or line that is not visible in the provided diff, omit the inline comment.
12
- 4. Mention the tool and check name in each comment.
11
+ ## Summary Selection
12
+
13
+ 1. Cover at most the five most important themes.
14
+ Group related findings from the same tool or the same underlying cause into one theme rather than listing them separately.
15
+ 2. Mention the tool and check name for each theme, but do not centre the narrative on tool names.
16
+ 3. Do not quote line numbers or file paths; the annotations already carry that detail.
13
17
 
14
18
  ## Output Format
15
19
 
16
- Output ONLY valid JSON. Do not wrap it in markdown fences. Do not include explanatory text before or after the JSON.
20
+ Output ONLY valid JSON.
21
+ Do not wrap it in markdown fences.
22
+ Do not include explanatory text before or after the JSON.
17
23
 
18
24
  The JSON MUST match this schema:
19
25
 
20
26
  ```json
21
27
  {
22
- "body": "<short markdown summary for the PR review body>",
23
- "comments": [
24
- {
25
- "path": "<repository-relative file path>",
26
- "line": <right-side line number from the diff>,
27
- "body": "<markdown review comment>"
28
- }
29
- ]
28
+ "body": "<short markdown narrative summary for the sticky PR comment>"
30
29
  }
31
30
  ```
32
31
 
32
+ ## Comment format
33
33
 
34
- ## Comment format:
34
+ Prioritise readability and actionability.
35
+ Assume the reader is a junior developer, or someone who is not familiar with the language and framework.
36
+ Be helpful, without being overly verbose.
35
37
 
36
- The comments should prioritise readability and actionabilty. Assume the reader is a junior developer, or someone who is not familiar with the language and framework. Be helpful, without being overly verbose.
38
+ Write one short paragraph per theme.
39
+ Separate each theme's paragraph from the next with a blank line - never merge multiple themes into a single paragraph.
40
+ End each theme's paragraph with its own `(Ref: ...)` tag naming the tool and check.
37
41
 
38
42
  Example format:
39
43
  ```
40
- This code appears to have X issue. That may be likely to cause Y problem. Consider an alternative soltion, such as Z.
44
+ This change introduces a fairly complex method that will be expensive to maintain as it grows further.
45
+ Consider breaking it into smaller, named steps.
46
+
47
+ _(Ref: Flog)_
48
+
49
+ There's a repeated pattern here that could be extracted into a shared helper, which would also make the duplication easier to spot next time it happens.
41
50
 
42
- _(Ref: Reek TooManyStatements, DuplicateMethodCall; Fasterer HashKeysEach)_
51
+ _(Ref: Reek DuplicateMethodCall)_
43
52
  ```
44
53
 
45
- ## Empty output:
54
+ ## Empty output
46
55
 
47
- If there are no high-confidence inline comments, return:
56
+ If there are no high-confidence findings worth reporting, return:
48
57
 
49
58
  ```json
50
59
  {
51
- "body": "Cleo quality review did not find any high-confidence issues worth inline PR comments.",
52
- "comments": []
60
+ "body": ""
53
61
  }
54
62
  ```
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cleo_quality_review
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gavin Morrice
@@ -76,12 +76,9 @@ files:
76
76
  - lib/cleo_quality_review/command_runner.rb
77
77
  - lib/cleo_quality_review/concurrent_executor.rb
78
78
  - lib/cleo_quality_review/configuration.rb
79
- - lib/cleo_quality_review/diff_map.rb
80
79
  - lib/cleo_quality_review/formatter.rb
81
80
  - lib/cleo_quality_review/git_diff_base.rb
82
81
  - lib/cleo_quality_review/github_client.rb
83
- - lib/cleo_quality_review/github_review_builder.rb
84
- - lib/cleo_quality_review/github_review_publisher.rb
85
82
  - lib/cleo_quality_review/incremental_base_resolver.rb
86
83
  - lib/cleo_quality_review/llm_client.rb
87
84
  - lib/cleo_quality_review/llm_config.rb
@@ -100,6 +97,8 @@ files:
100
97
  - lib/cleo_quality_review/run_artifacts.rb
101
98
  - lib/cleo_quality_review/run_artifacts/raw_check_outputs.rb
102
99
  - lib/cleo_quality_review/runner.rb
100
+ - lib/cleo_quality_review/sticky_comment_builder.rb
101
+ - lib/cleo_quality_review/sticky_comment_publisher.rb
103
102
  - lib/cleo_quality_review/target_resolver.rb
104
103
  - lib/cleo_quality_review/version.rb
105
104
  - prompts/agent.md
@@ -125,7 +124,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
124
  - !ruby/object:Gem::Version
126
125
  version: '0'
127
126
  requirements: []
128
- rubygems_version: 4.0.10
127
+ rubygems_version: 4.0.17
129
128
  specification_version: 4
130
129
  summary: Local Cleo code quality checks
131
130
  test_files: []
@@ -1,95 +0,0 @@
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
@@ -1,148 +0,0 @@
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 ||= parse_rendered_review
99
- end
100
-
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
112
- rescue JSON::ParserError => e
113
- raise Error, "pr_review output was not valid JSON: #{e.message}"
114
- end
115
-
116
- def review_body(comments)
117
- [
118
- marker,
119
- body_text,
120
- inline_summary(comments),
121
- ].compact.join("\n\n")
122
- end
123
-
124
- def body_text
125
- parsed_review.fetch("body", "").to_s.strip
126
- end
127
-
128
- def inline_summary(comments)
129
- published_count = comments.length
130
- requested_count = rendered_comments.length
131
- omitted_comments_message(published_count, requested_count)
132
- end
133
-
134
- def omitted_comments_message(published, requested)
135
- return "No rendered comments mapped to commentable PR diff lines." if published.zero? && requested.positive?
136
- return if published == requested
137
-
138
- omitted = requested - published
139
- "#{omitted} rendered comment#{'s' unless omitted == 1} were omitted because they did not map to commentable PR diff lines."
140
- end
141
-
142
- def truncate(value)
143
- return value if value.length <= MAX_BODY_LENGTH
144
-
145
- "#{value[0, MAX_BODY_LENGTH - 20]}\n\n[truncated]"
146
- end
147
- end
148
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- require_relative "github_client"
6
- require_relative "github_review_builder"
7
- require_relative "llm_errors"
8
-
9
- module CleoQualityReview
10
- ##
11
- # Publishes quality review findings as a GitHub pull request review
12
- class GitHubReviewPublisher
13
- ##
14
- # @param [Run] run completed quality review run
15
- # @param [String] rendered_review JSON produced by the pr_review formatter
16
- # @param [Hash{String => String}] env process environment
17
- # @param [GitHubClient, nil] client GitHub API client (built from env when omitted)
18
- def initialize(run:, rendered_review:, env: ENV, client: nil)
19
- @run = run
20
- @rendered_review = rendered_review
21
- @env = env
22
- @client = client
23
- end
24
-
25
- ##
26
- # Publish the review, or skip when there is no PR context/findings
27
- # @return [String] status message
28
- def publish
29
- skip_reason = publication_skip_reason
30
- return skip_reason if skip_reason
31
-
32
- post_review
33
- end
34
-
35
- private
36
-
37
- def publication_skip_reason
38
- review_id = run.review_id
39
- return "No PR review comments to publish." if builder.empty?
40
- return "No pull_request event found; skipping PR review publication." unless pull_request_context?
41
- return "PR review already published for review ID #{review_id}; skipping." if already_published?
42
-
43
- nil
44
- end
45
-
46
- def post_review
47
- response = client.post(reviews_path, builder.payload(commit_id: head_sha))
48
- raise Error, "GitHub PR review publication failed with status #{response.status_code}: #{response.body}" unless response.success?
49
-
50
- "Published PR review for review ID #{run.review_id}."
51
- end
52
-
53
- attr_reader :env, :rendered_review, :run
54
-
55
- def already_published?
56
- response = client.get(reviews_path)
57
- body = response.body
58
- raise Error, "GitHub PR review lookup failed with status #{response.status_code}: #{body}" unless response.success?
59
-
60
- JSON.parse(body).any? do |review|
61
- review.fetch("body", "").include?(builder.marker)
62
- end
63
- end
64
-
65
- def client
66
- @client ||= GitHubClient.new(token: token, api_url: api_url)
67
- end
68
-
69
- def builder
70
- @builder ||= GitHubReviewBuilder.new(run: run, rendered_review: rendered_review)
71
- end
72
-
73
- def pull_request_context?
74
- event.fetch("pull_request", nil).is_a?(Hash)
75
- end
76
-
77
- def reviews_path
78
- "/repos/#{repository}/pulls/#{pull_request_number}/reviews"
79
- end
80
-
81
- def pull_request_number
82
- event["number"] || event.fetch("pull_request").fetch("number")
83
- end
84
-
85
- def head_sha
86
- event.fetch("pull_request").fetch("head").fetch("sha")
87
- end
88
-
89
- def repository
90
- env.fetch("GITHUB_REPOSITORY")
91
- end
92
-
93
- def api_url
94
- env.fetch("GITHUB_API_URL", "https://api.github.com")
95
- end
96
-
97
- def event
98
- @event ||= JSON.parse(File.read(env.fetch("GITHUB_EVENT_PATH")))
99
- end
100
-
101
- def token
102
- env.fetch("GITHUB_TOKEN")
103
- end
104
- end
105
- end