ace-review 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
- data/.ace-defaults/review/config.yml +79 -0
- data/.ace-defaults/review/presets/code-fit.yml +64 -0
- data/.ace-defaults/review/presets/code-shine.yml +44 -0
- data/.ace-defaults/review/presets/code-valid.yml +39 -0
- data/.ace-defaults/review/presets/docs.yml +42 -0
- data/.ace-defaults/review/presets/spec.yml +37 -0
- data/CHANGELOG.md +1780 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-review +27 -0
- data/exe/ace-review-feedback +17 -0
- data/handbook/guides/code-review-process.g.md +234 -0
- data/handbook/prompts/base/sections.md +23 -0
- data/handbook/prompts/base/system.md +60 -0
- data/handbook/prompts/focus/architecture/atom.md +30 -0
- data/handbook/prompts/focus/architecture/reflection.md +60 -0
- data/handbook/prompts/focus/frameworks/rails.md +40 -0
- data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
- data/handbook/prompts/focus/languages/ruby.md +50 -0
- data/handbook/prompts/focus/phase/correctness.md +51 -0
- data/handbook/prompts/focus/phase/polish.md +43 -0
- data/handbook/prompts/focus/phase/quality.md +42 -0
- data/handbook/prompts/focus/quality/performance.md +48 -0
- data/handbook/prompts/focus/quality/security.md +47 -0
- data/handbook/prompts/focus/scope/docs.md +38 -0
- data/handbook/prompts/focus/scope/spec.md +58 -0
- data/handbook/prompts/focus/scope/tests.md +36 -0
- data/handbook/prompts/format/compact.md +12 -0
- data/handbook/prompts/format/detailed.md +39 -0
- data/handbook/prompts/format/standard.md +16 -0
- data/handbook/prompts/guidelines/icons.md +19 -0
- data/handbook/prompts/guidelines/tone.md +21 -0
- data/handbook/prompts/synthesis-review-reports.system.md +318 -0
- data/handbook/prompts/synthesize-feedback.system.md +147 -0
- data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
- data/handbook/skills/as-review-package/SKILL.md +36 -0
- data/handbook/skills/as-review-pr/SKILL.md +38 -0
- data/handbook/skills/as-review-run/SKILL.md +30 -0
- data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
- data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
- data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
- data/handbook/workflow-instructions/review/package.wf.md +16 -0
- data/handbook/workflow-instructions/review/pr.wf.md +284 -0
- data/handbook/workflow-instructions/review/run.wf.md +262 -0
- data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
- data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
- data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
- data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
- data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
- data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
- data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
- data/lib/ace/review/atoms/preset_validator.rb +103 -0
- data/lib/ace/review/atoms/priority_filter.rb +115 -0
- data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
- data/lib/ace/review/atoms/slug_generator.rb +50 -0
- data/lib/ace/review/atoms/token_estimator.rb +86 -0
- data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
- data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
- data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
- data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
- data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
- data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
- data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
- data/lib/ace/review/cli/commands/feedback.rb +79 -0
- data/lib/ace/review/cli/commands/review.rb +378 -0
- data/lib/ace/review/cli/feedback_cli.rb +71 -0
- data/lib/ace/review/cli.rb +103 -0
- data/lib/ace/review/errors.rb +146 -0
- data/lib/ace/review/models/feedback_item.rb +216 -0
- data/lib/ace/review/models/review_options.rb +208 -0
- data/lib/ace/review/models/reviewer.rb +181 -0
- data/lib/ace/review/molecules/context_composer.rb +123 -0
- data/lib/ace/review/molecules/context_extractor.rb +159 -0
- data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
- data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
- data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
- data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
- data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
- data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
- data/lib/ace/review/molecules/llm_executor.rb +142 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
- data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
- data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
- data/lib/ace/review/molecules/preset_manager.rb +494 -0
- data/lib/ace/review/molecules/prompt_composer.rb +76 -0
- data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
- data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
- data/lib/ace/review/molecules/subject_extractor.rb +315 -0
- data/lib/ace/review/molecules/subject_filter.rb +199 -0
- data/lib/ace/review/molecules/subject_strategy.rb +96 -0
- data/lib/ace/review/molecules/task_report_saver.rb +161 -0
- data/lib/ace/review/molecules/task_resolver.rb +48 -0
- data/lib/ace/review/organisms/feedback_manager.rb +386 -0
- data/lib/ace/review/organisms/review_manager.rb +1059 -0
- data/lib/ace/review/version.rb +7 -0
- data/lib/ace/review.rb +135 -0
- metadata +351 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Review
|
|
8
|
+
module Atoms
|
|
9
|
+
# Format PR comments into a structured markdown report
|
|
10
|
+
# Pure transformation - no side effects
|
|
11
|
+
class PrCommentFormatter
|
|
12
|
+
# Review state to human-readable status
|
|
13
|
+
REVIEW_STATES = {
|
|
14
|
+
"APPROVED" => "Approved",
|
|
15
|
+
"CHANGES_REQUESTED" => "Changes Requested",
|
|
16
|
+
"COMMENTED" => "Commented",
|
|
17
|
+
"DISMISSED" => "Dismissed",
|
|
18
|
+
"PENDING" => "Pending"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Format comments data into markdown report
|
|
22
|
+
#
|
|
23
|
+
# @param comments_data [Hash] Data from GhPrCommentFetcher
|
|
24
|
+
# @return [String] Formatted markdown report
|
|
25
|
+
def self.format(comments_data)
|
|
26
|
+
return nil unless comments_data && comments_data[:success]
|
|
27
|
+
|
|
28
|
+
pr_number = comments_data[:pr_number]
|
|
29
|
+
pr_title = comments_data[:pr_title]
|
|
30
|
+
comments = comments_data[:comments] || []
|
|
31
|
+
reviews = comments_data[:reviews] || []
|
|
32
|
+
review_threads = comments_data[:review_threads] || []
|
|
33
|
+
|
|
34
|
+
# Build report sections
|
|
35
|
+
frontmatter = build_frontmatter(comments_data)
|
|
36
|
+
summary = build_summary(comments, reviews, review_threads, pr_number, pr_title)
|
|
37
|
+
inline_section = build_inline_comments_section(review_threads)
|
|
38
|
+
unresolved_section = build_unresolved_section(comments, reviews)
|
|
39
|
+
resolved_section = build_resolved_section(reviews)
|
|
40
|
+
comments_table = build_comments_table(comments, reviews)
|
|
41
|
+
|
|
42
|
+
# Combine sections
|
|
43
|
+
sections = [frontmatter, summary]
|
|
44
|
+
sections << inline_section unless inline_section.nil?
|
|
45
|
+
sections << unresolved_section unless unresolved_section.nil?
|
|
46
|
+
sections << resolved_section unless resolved_section.nil?
|
|
47
|
+
sections << comments_table unless comments_table.nil?
|
|
48
|
+
|
|
49
|
+
sections.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Build YAML frontmatter
|
|
53
|
+
#
|
|
54
|
+
# @param comments_data [Hash] Comments data
|
|
55
|
+
# @return [String] YAML frontmatter block
|
|
56
|
+
def self.build_frontmatter(comments_data)
|
|
57
|
+
metadata = {
|
|
58
|
+
"source" => "pr-comments",
|
|
59
|
+
"pr_number" => comments_data[:pr_number],
|
|
60
|
+
"fetched_at" => Time.now.utc.iso8601
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
"---\n#{YAML.dump(metadata).sub(/^---\n/, "")}---\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Build summary section
|
|
67
|
+
#
|
|
68
|
+
# @param comments [Array<Hash>] Issue comments
|
|
69
|
+
# @param reviews [Array<Hash>] Code reviews
|
|
70
|
+
# @param review_threads [Array<Hash>] Inline review threads
|
|
71
|
+
# @param pr_number [Integer] PR number
|
|
72
|
+
# @param pr_title [String] PR title
|
|
73
|
+
# @return [String] Summary markdown
|
|
74
|
+
def self.build_summary(comments, reviews, review_threads, pr_number, pr_title)
|
|
75
|
+
total_comments = comments.size + reviews.size
|
|
76
|
+
inline_thread_count = review_threads.size
|
|
77
|
+
reviewers = extract_reviewers(comments, reviews, review_threads)
|
|
78
|
+
unresolved_count = count_unresolved(comments, reviews, review_threads)
|
|
79
|
+
|
|
80
|
+
summary = "# Developer Feedback from PR ##{pr_number}\n\n"
|
|
81
|
+
summary += "> #{pr_title}\n\n" if pr_title && !pr_title.empty?
|
|
82
|
+
summary += "## Summary\n\n"
|
|
83
|
+
summary += "- Total comments: #{total_comments}\n"
|
|
84
|
+
summary += "- Inline code comments: #{inline_thread_count}\n" if inline_thread_count > 0
|
|
85
|
+
summary += "- Unresolved items: #{unresolved_count}\n"
|
|
86
|
+
summary += "- Reviewers: #{reviewers.map { |r| "@#{r}" }.join(", ")}\n" if reviewers.any?
|
|
87
|
+
summary
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build unresolved feedback section
|
|
91
|
+
#
|
|
92
|
+
# @param comments [Array<Hash>] Issue comments
|
|
93
|
+
# @param reviews [Array<Hash>] Code reviews
|
|
94
|
+
# @return [String, nil] Unresolved section or nil if empty
|
|
95
|
+
def self.build_unresolved_section(comments, reviews)
|
|
96
|
+
unresolved = []
|
|
97
|
+
|
|
98
|
+
# Add comments (all issue comments are considered unresolved/open)
|
|
99
|
+
comments.each do |comment|
|
|
100
|
+
unresolved << {
|
|
101
|
+
author: comment[:author],
|
|
102
|
+
body: comment[:body],
|
|
103
|
+
id: comment[:id],
|
|
104
|
+
type: "comment",
|
|
105
|
+
created_at: comment[:created_at]
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add reviews with "CHANGES_REQUESTED" state
|
|
110
|
+
reviews.select { |r| r[:state] == "CHANGES_REQUESTED" }.each do |review|
|
|
111
|
+
unresolved << {
|
|
112
|
+
author: review[:author],
|
|
113
|
+
body: review[:body],
|
|
114
|
+
id: review[:id],
|
|
115
|
+
type: "changes_requested",
|
|
116
|
+
created_at: review[:created_at]
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return nil if unresolved.empty?
|
|
121
|
+
|
|
122
|
+
section = "\n## Unresolved Feedback\n\n"
|
|
123
|
+
|
|
124
|
+
unresolved.each do |item|
|
|
125
|
+
section += "### @#{item[:author]} (#{item[:type]}: #{item[:id]})\n\n"
|
|
126
|
+
section += format_body(item[:body])
|
|
127
|
+
section += "\n\n"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
section
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Build inline code comments section
|
|
134
|
+
#
|
|
135
|
+
# @param review_threads [Array<Hash>] Review threads from GraphQL
|
|
136
|
+
# @return [String, nil] Inline comments section or nil if empty
|
|
137
|
+
def self.build_inline_comments_section(review_threads)
|
|
138
|
+
return nil if review_threads.nil? || review_threads.empty?
|
|
139
|
+
|
|
140
|
+
section = "\n## Inline Code Comments\n\n"
|
|
141
|
+
|
|
142
|
+
review_threads.each do |thread|
|
|
143
|
+
path = thread[:path] || "unknown"
|
|
144
|
+
line = thread[:line]
|
|
145
|
+
thread_id = thread[:id]
|
|
146
|
+
is_resolved = thread[:is_resolved]
|
|
147
|
+
status = is_resolved ? "Resolved" : "Unresolved"
|
|
148
|
+
|
|
149
|
+
# Header with file:line, thread ID, and status
|
|
150
|
+
location = line ? "#{path}:#{line}" : path
|
|
151
|
+
section += "### #{location} (thread: #{thread_id}) - #{status}\n\n"
|
|
152
|
+
|
|
153
|
+
# Format each comment in the thread
|
|
154
|
+
(thread[:comments] || []).each do |comment|
|
|
155
|
+
author = comment[:author] || "unknown"
|
|
156
|
+
body = comment[:body]&.strip
|
|
157
|
+
next if body.nil? || body.empty?
|
|
158
|
+
|
|
159
|
+
# Quote the comment with author prefix
|
|
160
|
+
section += "> @#{author}: #{body.gsub("\n", "\n> ")}\n\n"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
section
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Build resolved feedback section
|
|
168
|
+
#
|
|
169
|
+
# @param reviews [Array<Hash>] Code reviews
|
|
170
|
+
# @return [String, nil] Resolved section or nil if empty
|
|
171
|
+
def self.build_resolved_section(reviews)
|
|
172
|
+
resolved = []
|
|
173
|
+
|
|
174
|
+
# Add approvals
|
|
175
|
+
reviews.select { |r| r[:state] == "APPROVED" }.each do |review|
|
|
176
|
+
resolved << "@#{review[:author]} approved changes"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
return nil if resolved.empty?
|
|
180
|
+
|
|
181
|
+
section = "\n## Resolved Feedback\n\n"
|
|
182
|
+
resolved.each do |item|
|
|
183
|
+
section += "- #{item}\n"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
section
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Build comments table for quick reference
|
|
190
|
+
#
|
|
191
|
+
# @param comments [Array<Hash>] Issue comments
|
|
192
|
+
# @param reviews [Array<Hash>] Code reviews
|
|
193
|
+
# @return [String, nil] Table markdown or nil if empty
|
|
194
|
+
def self.build_comments_table(comments, reviews)
|
|
195
|
+
all_items = []
|
|
196
|
+
|
|
197
|
+
comments.each do |c|
|
|
198
|
+
all_items << {
|
|
199
|
+
author: c[:author],
|
|
200
|
+
type: "Comment",
|
|
201
|
+
status: "Open",
|
|
202
|
+
preview: truncate_body(c[:body], 50),
|
|
203
|
+
id: c[:id]
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
reviews.each do |r|
|
|
208
|
+
status = REVIEW_STATES[r[:state]] || r[:state]
|
|
209
|
+
all_items << {
|
|
210
|
+
author: r[:author],
|
|
211
|
+
type: "Review",
|
|
212
|
+
status: status,
|
|
213
|
+
preview: truncate_body(r[:body], 50),
|
|
214
|
+
id: r[:id]
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
return nil if all_items.empty?
|
|
219
|
+
|
|
220
|
+
table = "\n## All Comments\n\n"
|
|
221
|
+
table += "| Author | Type | Status | Comment | ID |\n"
|
|
222
|
+
table += "|--------|------|--------|---------|----|\n"
|
|
223
|
+
|
|
224
|
+
all_items.each do |item|
|
|
225
|
+
# Wrap ID in backticks for readability
|
|
226
|
+
table += "| @#{item[:author]} | #{item[:type]} | #{item[:status]} | #{item[:preview]} | `#{item[:id]}` |\n"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
table
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Extract unique reviewers from comments, reviews, and threads
|
|
233
|
+
#
|
|
234
|
+
# @param comments [Array<Hash>] Issue comments
|
|
235
|
+
# @param reviews [Array<Hash>] Code reviews
|
|
236
|
+
# @param review_threads [Array<Hash>] Inline review threads
|
|
237
|
+
# @return [Array<String>] Unique reviewer usernames
|
|
238
|
+
def self.extract_reviewers(comments, reviews, review_threads = [])
|
|
239
|
+
authors = []
|
|
240
|
+
authors.concat(comments.map { |c| c[:author] })
|
|
241
|
+
authors.concat(reviews.map { |r| r[:author] })
|
|
242
|
+
# Extract authors from review thread comments
|
|
243
|
+
review_threads.each do |thread|
|
|
244
|
+
(thread[:comments] || []).each do |comment|
|
|
245
|
+
authors << comment[:author]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
authors.uniq.compact.sort
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Count unresolved items
|
|
252
|
+
# Counts actionable items (not informational/FYI comments)
|
|
253
|
+
#
|
|
254
|
+
# @param comments [Array<Hash>] Issue comments
|
|
255
|
+
# @param reviews [Array<Hash>] Code reviews
|
|
256
|
+
# @param review_threads [Array<Hash>] Inline review threads
|
|
257
|
+
# @return [Integer] Count of unresolved items
|
|
258
|
+
def self.count_unresolved(comments, reviews, review_threads = [])
|
|
259
|
+
unresolved_threads = review_threads.count { |t| !t[:is_resolved] }
|
|
260
|
+
actionable_comments = comments.count { |c| actionable_comment?(c[:body]) }
|
|
261
|
+
actionable_comments + reviews.count { |r| r[:state] == "CHANGES_REQUESTED" } + unresolved_threads
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Check if a comment appears to be actionable (vs informational/FYI)
|
|
265
|
+
# Heuristic: contains question marks, action words, or change requests
|
|
266
|
+
#
|
|
267
|
+
# @param body [String] Comment body
|
|
268
|
+
# @return [Boolean] true if comment seems actionable
|
|
269
|
+
def self.actionable_comment?(body)
|
|
270
|
+
return true if body.nil? || body.empty? # Default to actionable if can't determine
|
|
271
|
+
|
|
272
|
+
lowered = body.downcase
|
|
273
|
+
# Action indicators
|
|
274
|
+
lowered.include?("?") || # Questions
|
|
275
|
+
lowered.include?("please") || # Polite requests
|
|
276
|
+
lowered.include?("should") || # Suggestions
|
|
277
|
+
lowered.include?("could you") || # Requests
|
|
278
|
+
lowered.include?("need to") || # Requirements
|
|
279
|
+
lowered.include?("must") || # Requirements
|
|
280
|
+
lowered.include?("fix") || # Fix requests
|
|
281
|
+
lowered.include?("change") || # Change requests
|
|
282
|
+
lowered.include?("update") || # Update requests
|
|
283
|
+
lowered.include?("consider") || # Suggestions
|
|
284
|
+
lowered.match?(/\btodo\b/i) # TODOs
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Format comment body with proper indentation/quoting
|
|
288
|
+
#
|
|
289
|
+
# @param body [String] Comment body
|
|
290
|
+
# @return [String] Formatted body
|
|
291
|
+
def self.format_body(body)
|
|
292
|
+
return "" if body.nil? || body.empty?
|
|
293
|
+
|
|
294
|
+
# Quote each line
|
|
295
|
+
body.lines.map { |line| "> #{line.rstrip}" }.join("\n")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Truncate body for table preview
|
|
299
|
+
#
|
|
300
|
+
# @param body [String] Comment body
|
|
301
|
+
# @param max_length [Integer] Maximum length
|
|
302
|
+
# @return [String] Truncated body (pipe-safe for markdown tables)
|
|
303
|
+
def self.truncate_body(body, max_length)
|
|
304
|
+
return "" if body.nil?
|
|
305
|
+
|
|
306
|
+
# Replace newlines with visual indicator, collapse other whitespace
|
|
307
|
+
cleaned = body.gsub(/[\r\n]+/, " \u21b5 ").gsub(/[ \t]+/, " ").strip
|
|
308
|
+
|
|
309
|
+
# Escape pipe characters to prevent breaking markdown tables
|
|
310
|
+
cleaned = cleaned.gsub("|", "\\|")
|
|
311
|
+
|
|
312
|
+
if cleaned.length > max_length
|
|
313
|
+
"#{cleaned[0...max_length]}..."
|
|
314
|
+
else
|
|
315
|
+
cleaned
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private_class_method :build_frontmatter, :build_summary, :build_inline_comments_section,
|
|
320
|
+
:build_unresolved_section, :build_resolved_section, :build_comments_table,
|
|
321
|
+
:extract_reviewers, :count_unresolved, :actionable_comment?, :format_body, :truncate_body
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Validates preset references and detects circular dependencies
|
|
7
|
+
class PresetValidator
|
|
8
|
+
# Maximum recursion depth for preset composition
|
|
9
|
+
# A sane limit to prevent stack overflows from deep, non-circular nesting
|
|
10
|
+
MAX_DEPTH = 10
|
|
11
|
+
|
|
12
|
+
# Validate preset name format
|
|
13
|
+
# Returns { success: true } if valid
|
|
14
|
+
# Returns { success: false, error: "..." } if invalid
|
|
15
|
+
def self.validate_preset_name(preset_name)
|
|
16
|
+
return {success: false, error: "Preset name cannot be nil or empty"} if preset_name.nil? || preset_name.empty?
|
|
17
|
+
|
|
18
|
+
# Check for path traversal attempts
|
|
19
|
+
if preset_name.start_with?("/", "\\")
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
error: "Invalid preset name '#{preset_name}': absolute paths are not allowed"
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if preset_name.include?("..") || preset_name.include?("/") || preset_name.include?("\\")
|
|
27
|
+
return {
|
|
28
|
+
success: false,
|
|
29
|
+
error: "Invalid preset name '#{preset_name}': cannot contain path separators or '..' sequences"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check for reasonable length
|
|
34
|
+
if preset_name.length > 100
|
|
35
|
+
return {
|
|
36
|
+
success: false,
|
|
37
|
+
error: "Preset name too long (max 100 characters): '#{preset_name[0..20]}...'"
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{success: true}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if a preset exists in the preset manager
|
|
45
|
+
def self.preset_exists?(preset_name, preset_manager)
|
|
46
|
+
preset_manager.preset_exists?(preset_name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Detect circular dependencies in preset composition
|
|
50
|
+
# Returns { success: true } if no circular dependency
|
|
51
|
+
# Returns { success: false, error: "..." } if circular dependency found
|
|
52
|
+
def self.check_circular_dependency(preset_name, preset_chain)
|
|
53
|
+
if preset_chain.include?(preset_name)
|
|
54
|
+
{
|
|
55
|
+
success: false,
|
|
56
|
+
error: "Circular dependency detected: #{(preset_chain + [preset_name]).join(" -> ")}"
|
|
57
|
+
}
|
|
58
|
+
elsif preset_chain.size >= MAX_DEPTH
|
|
59
|
+
{
|
|
60
|
+
success: false,
|
|
61
|
+
error: "Maximum preset nesting depth (#{MAX_DEPTH}) exceeded: #{preset_chain.join(" -> ")}"
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
{success: true}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Validate a list of preset names
|
|
69
|
+
# Returns { success: true, valid: [], missing: [] }
|
|
70
|
+
def self.validate_presets(preset_names, preset_manager)
|
|
71
|
+
valid = []
|
|
72
|
+
missing = []
|
|
73
|
+
|
|
74
|
+
preset_names.each do |name|
|
|
75
|
+
if preset_exists?(name, preset_manager)
|
|
76
|
+
valid << name
|
|
77
|
+
else
|
|
78
|
+
missing << name
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
success: missing.empty?,
|
|
84
|
+
valid: valid,
|
|
85
|
+
missing: missing
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Extract preset references from a preset's configuration
|
|
90
|
+
# Returns array of preset names referenced in the 'presets:' key at root level
|
|
91
|
+
def self.extract_preset_references(preset_data)
|
|
92
|
+
return [] unless preset_data
|
|
93
|
+
|
|
94
|
+
# Look for root-level 'presets' key (both string and symbol)
|
|
95
|
+
presets = preset_data["presets"] || preset_data[:presets] || []
|
|
96
|
+
|
|
97
|
+
# Ensure we return an array of strings
|
|
98
|
+
Array(presets).map(&:to_s)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Filters feedback items by priority level with optional range support.
|
|
7
|
+
#
|
|
8
|
+
# Supports exact matching ("high") and inclusive range matching ("high+").
|
|
9
|
+
# Range matching includes the specified priority and all higher priorities.
|
|
10
|
+
#
|
|
11
|
+
# Priority hierarchy (highest to lowest): critical > high > medium > low
|
|
12
|
+
#
|
|
13
|
+
# @example Exact matching
|
|
14
|
+
# PriorityFilter.matches?("high", "high")
|
|
15
|
+
# #=> true
|
|
16
|
+
#
|
|
17
|
+
# PriorityFilter.matches?("medium", "high")
|
|
18
|
+
# #=> false
|
|
19
|
+
#
|
|
20
|
+
# @example Range matching with + suffix
|
|
21
|
+
# PriorityFilter.matches?("high", "medium+")
|
|
22
|
+
# #=> true (high >= medium)
|
|
23
|
+
#
|
|
24
|
+
# PriorityFilter.matches?("critical", "medium+")
|
|
25
|
+
# #=> true (critical >= medium)
|
|
26
|
+
#
|
|
27
|
+
# PriorityFilter.matches?("low", "medium+")
|
|
28
|
+
# #=> false (low < medium)
|
|
29
|
+
#
|
|
30
|
+
class PriorityFilter
|
|
31
|
+
# Priority levels in descending order (highest first)
|
|
32
|
+
PRIORITY_ORDER = {
|
|
33
|
+
"critical" => 4,
|
|
34
|
+
"high" => 3,
|
|
35
|
+
"medium" => 2,
|
|
36
|
+
"low" => 1
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
VALID_PRIORITIES = PRIORITY_ORDER.keys.freeze
|
|
40
|
+
|
|
41
|
+
# Parse a priority filter string into its components
|
|
42
|
+
#
|
|
43
|
+
# @param filter_string [String] Priority filter like "medium" or "medium+"
|
|
44
|
+
# @return [Hash, nil] Hash with :priority and :inclusive keys, or nil if invalid
|
|
45
|
+
#
|
|
46
|
+
# @example Exact match
|
|
47
|
+
# PriorityFilter.parse("high")
|
|
48
|
+
# #=> { priority: "high", inclusive: false }
|
|
49
|
+
#
|
|
50
|
+
# @example Range match
|
|
51
|
+
# PriorityFilter.parse("medium+")
|
|
52
|
+
# #=> { priority: "medium", inclusive: true }
|
|
53
|
+
#
|
|
54
|
+
# @example Invalid priority
|
|
55
|
+
# PriorityFilter.parse("urgent")
|
|
56
|
+
# #=> nil
|
|
57
|
+
def self.parse(filter_string)
|
|
58
|
+
return nil if filter_string.nil? || filter_string.empty?
|
|
59
|
+
|
|
60
|
+
# Check for + suffix (inclusive range)
|
|
61
|
+
inclusive = filter_string.end_with?("+")
|
|
62
|
+
priority = inclusive ? filter_string.chomp("+") : filter_string
|
|
63
|
+
|
|
64
|
+
# Validate priority
|
|
65
|
+
return nil unless VALID_PRIORITIES.include?(priority)
|
|
66
|
+
|
|
67
|
+
{priority: priority, inclusive: inclusive}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if an item priority matches a filter string
|
|
71
|
+
#
|
|
72
|
+
# @param item_priority [String] The priority of the item ("critical", "high", etc.)
|
|
73
|
+
# @param filter_string [String] The filter string ("high" or "high+")
|
|
74
|
+
# @return [Boolean] True if the item priority matches the filter
|
|
75
|
+
#
|
|
76
|
+
# @example Exact match
|
|
77
|
+
# PriorityFilter.matches?("high", "high")
|
|
78
|
+
# #=> true
|
|
79
|
+
#
|
|
80
|
+
# PriorityFilter.matches?("medium", "high")
|
|
81
|
+
# #=> false
|
|
82
|
+
#
|
|
83
|
+
# @example Range match
|
|
84
|
+
# PriorityFilter.matches?("critical", "high+")
|
|
85
|
+
# #=> true
|
|
86
|
+
#
|
|
87
|
+
# PriorityFilter.matches?("high", "high+")
|
|
88
|
+
# #=> true
|
|
89
|
+
#
|
|
90
|
+
# PriorityFilter.matches?("medium", "high+")
|
|
91
|
+
# #=> false
|
|
92
|
+
def self.matches?(item_priority, filter_string)
|
|
93
|
+
return false if item_priority.nil? || filter_string.nil?
|
|
94
|
+
|
|
95
|
+
parsed = parse(filter_string)
|
|
96
|
+
return false if parsed.nil?
|
|
97
|
+
|
|
98
|
+
item_level = PRIORITY_ORDER[item_priority]
|
|
99
|
+
filter_level = PRIORITY_ORDER[parsed[:priority]]
|
|
100
|
+
|
|
101
|
+
# Unknown item priority doesn't match
|
|
102
|
+
return false if item_level.nil?
|
|
103
|
+
|
|
104
|
+
if parsed[:inclusive]
|
|
105
|
+
# Range match: item priority must be >= filter priority
|
|
106
|
+
item_level >= filter_level
|
|
107
|
+
else
|
|
108
|
+
# Exact match
|
|
109
|
+
item_priority == parsed[:priority]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure function for retrying operations with exponential backoff
|
|
7
|
+
#
|
|
8
|
+
# This atom provides a reusable retry mechanism with exponential backoff
|
|
9
|
+
# for operations that may experience transient failures (network issues,
|
|
10
|
+
# timeouts, temporary unavailability).
|
|
11
|
+
module RetryWithBackoff
|
|
12
|
+
# Retry a block with exponential backoff
|
|
13
|
+
#
|
|
14
|
+
# @param options [Hash] Retry options
|
|
15
|
+
# @option options [Integer] :max_retries Maximum retry attempts (default: 3)
|
|
16
|
+
# @option options [Integer] :initial_backoff Initial backoff in seconds (default: 1)
|
|
17
|
+
# @option options [Integer] :max_backoff Maximum backoff in seconds (default: 32)
|
|
18
|
+
# @option options [Proc] :retryable_check Custom proc to check if error is retryable.
|
|
19
|
+
# Receives result hash, should return boolean. Defaults to network error check.
|
|
20
|
+
# @option options [Class] :error_class Exception class to raise on retry exhaustion
|
|
21
|
+
# (default: Ace::Review::Errors::GhNetworkError)
|
|
22
|
+
# @yield Block to retry - should return a hash with :success and :stderr keys
|
|
23
|
+
# @return Result from successful execution
|
|
24
|
+
# @raise [error_class] if all retries exhausted
|
|
25
|
+
def self.execute(options = {})
|
|
26
|
+
max_retries = options[:max_retries] || 3
|
|
27
|
+
backoff = options[:initial_backoff] || 1
|
|
28
|
+
max_backoff = options[:max_backoff] || 32
|
|
29
|
+
retryable_check = options[:retryable_check] || method(:default_retryable_check)
|
|
30
|
+
error_class = options[:error_class] || Ace::Review::Errors::GhNetworkError
|
|
31
|
+
attempt = 0
|
|
32
|
+
|
|
33
|
+
loop do
|
|
34
|
+
result = yield
|
|
35
|
+
|
|
36
|
+
# Success - return result
|
|
37
|
+
return result if result[:success]
|
|
38
|
+
|
|
39
|
+
# Check if error is retryable using provided check or default
|
|
40
|
+
unless retryable_check.call(result)
|
|
41
|
+
return result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Increment attempt
|
|
45
|
+
attempt += 1
|
|
46
|
+
|
|
47
|
+
# Exhausted retries
|
|
48
|
+
if attempt >= max_retries
|
|
49
|
+
error_msg = result[:stderr] || result[:error] || "Unknown error"
|
|
50
|
+
raise error_class, "Operation failed after #{max_retries} retries: #{error_msg}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Wait before retry with exponential backoff, capped at max_backoff
|
|
54
|
+
sleep(backoff)
|
|
55
|
+
backoff = [backoff * 2, max_backoff].min
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Default check for retryable errors (network/timeout errors)
|
|
60
|
+
#
|
|
61
|
+
# @param result [Hash] Result from operation (should have :stderr or :error key)
|
|
62
|
+
# @return [Boolean] true if error is retryable
|
|
63
|
+
def self.default_retryable_check(result)
|
|
64
|
+
error_msg = (result[:stderr] || result[:error]).to_s.downcase
|
|
65
|
+
|
|
66
|
+
# Network-related errors are retryable
|
|
67
|
+
error_msg.include?("timeout") ||
|
|
68
|
+
error_msg.include?("connection") ||
|
|
69
|
+
error_msg.include?("network") ||
|
|
70
|
+
error_msg.include?("temporary failure")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Review
|
|
5
|
+
module Atoms
|
|
6
|
+
# Generates URL-safe slugs from text strings
|
|
7
|
+
#
|
|
8
|
+
# Handles edge cases:
|
|
9
|
+
# - Special characters replaced with hyphens
|
|
10
|
+
# - Consecutive hyphens collapsed
|
|
11
|
+
# - Leading/trailing hyphens removed
|
|
12
|
+
# - Length truncation for filesystem safety
|
|
13
|
+
class SlugGenerator
|
|
14
|
+
DEFAULT_MAX_LENGTH = 64
|
|
15
|
+
|
|
16
|
+
# Generate a URL-safe slug from text
|
|
17
|
+
#
|
|
18
|
+
# @param text [String] The text to convert to a slug
|
|
19
|
+
# @param max_length [Integer] Maximum slug length (default: 64)
|
|
20
|
+
# @return [String] URL-safe slug
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# SlugGenerator.generate("google:gemini-2.5-flash")
|
|
24
|
+
# #=> "google-gemini-2-5-flash"
|
|
25
|
+
#
|
|
26
|
+
# @example With special characters
|
|
27
|
+
# SlugGenerator.generate("model::name@provider")
|
|
28
|
+
# #=> "model-name-provider"
|
|
29
|
+
#
|
|
30
|
+
# @example With leading/trailing special chars
|
|
31
|
+
# SlugGenerator.generate("@model-name@")
|
|
32
|
+
# #=> "model-name"
|
|
33
|
+
#
|
|
34
|
+
# @example Long text truncation
|
|
35
|
+
# SlugGenerator.generate("very-long-model-name...", max_length: 10)
|
|
36
|
+
# #=> "very-long" (truncated, trailing hyphen removed)
|
|
37
|
+
def self.generate(text, max_length: DEFAULT_MAX_LENGTH)
|
|
38
|
+
return "" if text.nil? || text.empty?
|
|
39
|
+
|
|
40
|
+
text
|
|
41
|
+
.gsub(/[^a-zA-Z0-9\-_]/, "-").squeeze("-") # Collapse consecutive hyphens
|
|
42
|
+
.gsub(/\A-|-\z/, "") # Remove leading/trailing hyphens
|
|
43
|
+
.downcase
|
|
44
|
+
.slice(0, max_length) # Truncate to max length
|
|
45
|
+
.gsub(/-\z/, "") # Remove trailing hyphen after truncation
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|