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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. 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