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 +4 -4
- data/lib/cleo_quality_review/cli.rb +2 -2
- data/lib/cleo_quality_review/github_client.rb +10 -0
- data/lib/cleo_quality_review/incremental_base_resolver.rb +35 -39
- data/lib/cleo_quality_review/sticky_comment_builder.rb +76 -0
- data/lib/cleo_quality_review/sticky_comment_publisher.rb +143 -0
- data/lib/cleo_quality_review/version.rb +1 -1
- data/prompts/pr_review.md +31 -23
- metadata +4 -5
- data/lib/cleo_quality_review/diff_map.rb +0 -95
- data/lib/cleo_quality_review/github_review_builder.rb +0 -148
- data/lib/cleo_quality_review/github_review_publisher.rb +0 -105
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85099120967159c27f15cfa154de5e081e125b08c6c2e3e910081d392caafa04
|
|
4
|
+
data.tar.gz: 7b7e772fa82cabf8ef0add3a6c39af9c16a0a02d95648f1060716b750eac9ac1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 "
|
|
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 =
|
|
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
|
|
14
|
-
# ancestor of the current head, so only
|
|
15
|
-
# analysed. It falls back to +nil+
|
|
16
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
74
|
-
#
|
|
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
|
|
77
|
-
(1..
|
|
78
|
-
|
|
79
|
-
all.concat(
|
|
80
|
-
break all if
|
|
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
|
|
85
|
-
response = client.get("/repos/#{repository}/
|
|
86
|
-
raise Error, "GitHub
|
|
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
|
|
94
|
-
#
|
|
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?(
|
|
99
|
-
bot_authored?(
|
|
94
|
+
def quality_review?(comment)
|
|
95
|
+
bot_authored?(comment) && marked?(comment)
|
|
100
96
|
end
|
|
101
97
|
|
|
102
|
-
def bot_authored?(
|
|
103
|
-
|
|
98
|
+
def bot_authored?(comment)
|
|
99
|
+
comment.dig("user", "type") == "Bot"
|
|
104
100
|
end
|
|
105
101
|
|
|
106
|
-
def marked?(
|
|
107
|
-
|
|
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
|
data/prompts/pr_review.md
CHANGED
|
@@ -1,54 +1,62 @@
|
|
|
1
|
-
You are the pipeline interface between code quality tools and GitHub pull request
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
51
|
+
_(Ref: Reek DuplicateMethodCall)_
|
|
43
52
|
```
|
|
44
53
|
|
|
45
|
-
## Empty output
|
|
54
|
+
## Empty output
|
|
46
55
|
|
|
47
|
-
If there are no high-confidence
|
|
56
|
+
If there are no high-confidence findings worth reporting, return:
|
|
48
57
|
|
|
49
58
|
```json
|
|
50
59
|
{
|
|
51
|
-
"body": "
|
|
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
|
+
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.
|
|
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
|